@oxyhq/bloom 0.13.0 → 0.15.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 (140) hide show
  1. package/lib/commonjs/dialog/CenteredDialog.js +2 -0
  2. package/lib/commonjs/dialog/CenteredDialog.js.map +1 -1
  3. package/lib/commonjs/dialog/CenteredDialog.web.js +2 -0
  4. package/lib/commonjs/dialog/CenteredDialog.web.js.map +1 -1
  5. package/lib/commonjs/dialog/Dialog.js +337 -43
  6. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  7. package/lib/commonjs/dialog/Dialog.web.js +414 -63
  8. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  9. package/lib/commonjs/dialog/index.js +6 -0
  10. package/lib/commonjs/dialog/index.js.map +1 -1
  11. package/lib/commonjs/dialog/index.web.js +6 -0
  12. package/lib/commonjs/dialog/index.web.js.map +1 -1
  13. package/lib/commonjs/dialog/placement.js +104 -0
  14. package/lib/commonjs/dialog/placement.js.map +1 -0
  15. package/lib/commonjs/index.js +22 -0
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/index.web.js +28 -6
  18. package/lib/commonjs/index.web.js.map +1 -1
  19. package/lib/commonjs/responsive-sheet/ResponsiveSheet.js +97 -0
  20. package/lib/commonjs/responsive-sheet/ResponsiveSheet.js.map +1 -0
  21. package/lib/commonjs/responsive-sheet/ResponsiveSheet.web.js +84 -0
  22. package/lib/commonjs/responsive-sheet/ResponsiveSheet.web.js.map +1 -0
  23. package/lib/commonjs/responsive-sheet/index.js +19 -0
  24. package/lib/commonjs/responsive-sheet/index.js.map +1 -0
  25. package/lib/commonjs/responsive-sheet/index.web.js +19 -0
  26. package/lib/commonjs/responsive-sheet/index.web.js.map +1 -0
  27. package/lib/commonjs/responsive-sheet/types.js +6 -0
  28. package/lib/commonjs/responsive-sheet/types.js.map +1 -0
  29. package/lib/module/dialog/CenteredDialog.js +2 -0
  30. package/lib/module/dialog/CenteredDialog.js.map +1 -1
  31. package/lib/module/dialog/CenteredDialog.web.js +2 -0
  32. package/lib/module/dialog/CenteredDialog.web.js.map +1 -1
  33. package/lib/module/dialog/Dialog.js +336 -46
  34. package/lib/module/dialog/Dialog.js.map +1 -1
  35. package/lib/module/dialog/Dialog.web.js +412 -65
  36. package/lib/module/dialog/Dialog.web.js.map +1 -1
  37. package/lib/module/dialog/index.js +1 -1
  38. package/lib/module/dialog/index.js.map +1 -1
  39. package/lib/module/dialog/index.web.js +1 -1
  40. package/lib/module/dialog/index.web.js.map +1 -1
  41. package/lib/module/dialog/placement.js +100 -0
  42. package/lib/module/dialog/placement.js.map +1 -0
  43. package/lib/module/index.js +3 -1
  44. package/lib/module/index.js.map +1 -1
  45. package/lib/module/index.web.js +3 -1
  46. package/lib/module/index.web.js.map +1 -1
  47. package/lib/module/responsive-sheet/ResponsiveSheet.js +90 -0
  48. package/lib/module/responsive-sheet/ResponsiveSheet.js.map +1 -0
  49. package/lib/module/responsive-sheet/ResponsiveSheet.web.js +77 -0
  50. package/lib/module/responsive-sheet/ResponsiveSheet.web.js.map +1 -0
  51. package/lib/module/responsive-sheet/index.js +4 -0
  52. package/lib/module/responsive-sheet/index.js.map +1 -0
  53. package/lib/module/responsive-sheet/index.web.js +12 -0
  54. package/lib/module/responsive-sheet/index.web.js.map +1 -0
  55. package/lib/module/responsive-sheet/types.js +4 -0
  56. package/lib/module/responsive-sheet/types.js.map +1 -0
  57. package/lib/typescript/commonjs/dialog/CenteredDialog.d.ts +2 -0
  58. package/lib/typescript/commonjs/dialog/CenteredDialog.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/dialog/CenteredDialog.web.d.ts +2 -0
  60. package/lib/typescript/commonjs/dialog/CenteredDialog.web.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/dialog/Dialog.d.ts +18 -15
  62. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +18 -5
  64. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/dialog/centered-dialog-types.d.ts +4 -0
  66. package/lib/typescript/commonjs/dialog/centered-dialog-types.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/dialog/index.d.ts +2 -2
  68. package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/dialog/index.web.d.ts +2 -2
  70. package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/dialog/placement.d.ts +71 -0
  72. package/lib/typescript/commonjs/dialog/placement.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/dialog/types.d.ts +66 -3
  74. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/index.d.ts +4 -2
  76. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/index.web.d.ts +4 -2
  78. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.d.ts +22 -0
  80. package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.web.d.ts +16 -0
  82. package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.web.d.ts.map +1 -0
  83. package/lib/typescript/commonjs/responsive-sheet/index.d.ts +3 -0
  84. package/lib/typescript/commonjs/responsive-sheet/index.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/responsive-sheet/index.web.d.ts +3 -0
  86. package/lib/typescript/commonjs/responsive-sheet/index.web.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/responsive-sheet/types.d.ts +58 -0
  88. package/lib/typescript/commonjs/responsive-sheet/types.d.ts.map +1 -0
  89. package/lib/typescript/module/dialog/CenteredDialog.d.ts +2 -0
  90. package/lib/typescript/module/dialog/CenteredDialog.d.ts.map +1 -1
  91. package/lib/typescript/module/dialog/CenteredDialog.web.d.ts +2 -0
  92. package/lib/typescript/module/dialog/CenteredDialog.web.d.ts.map +1 -1
  93. package/lib/typescript/module/dialog/Dialog.d.ts +18 -15
  94. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  95. package/lib/typescript/module/dialog/Dialog.web.d.ts +18 -5
  96. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  97. package/lib/typescript/module/dialog/centered-dialog-types.d.ts +4 -0
  98. package/lib/typescript/module/dialog/centered-dialog-types.d.ts.map +1 -1
  99. package/lib/typescript/module/dialog/index.d.ts +2 -2
  100. package/lib/typescript/module/dialog/index.d.ts.map +1 -1
  101. package/lib/typescript/module/dialog/index.web.d.ts +2 -2
  102. package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
  103. package/lib/typescript/module/dialog/placement.d.ts +71 -0
  104. package/lib/typescript/module/dialog/placement.d.ts.map +1 -0
  105. package/lib/typescript/module/dialog/types.d.ts +66 -3
  106. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  107. package/lib/typescript/module/index.d.ts +4 -2
  108. package/lib/typescript/module/index.d.ts.map +1 -1
  109. package/lib/typescript/module/index.web.d.ts +4 -2
  110. package/lib/typescript/module/index.web.d.ts.map +1 -1
  111. package/lib/typescript/module/responsive-sheet/ResponsiveSheet.d.ts +22 -0
  112. package/lib/typescript/module/responsive-sheet/ResponsiveSheet.d.ts.map +1 -0
  113. package/lib/typescript/module/responsive-sheet/ResponsiveSheet.web.d.ts +16 -0
  114. package/lib/typescript/module/responsive-sheet/ResponsiveSheet.web.d.ts.map +1 -0
  115. package/lib/typescript/module/responsive-sheet/index.d.ts +3 -0
  116. package/lib/typescript/module/responsive-sheet/index.d.ts.map +1 -0
  117. package/lib/typescript/module/responsive-sheet/index.web.d.ts +3 -0
  118. package/lib/typescript/module/responsive-sheet/index.web.d.ts.map +1 -0
  119. package/lib/typescript/module/responsive-sheet/types.d.ts +58 -0
  120. package/lib/typescript/module/responsive-sheet/types.d.ts.map +1 -0
  121. package/package.json +18 -1
  122. package/src/__tests__/DialogPlacement.test.tsx +144 -0
  123. package/src/__tests__/ResponsiveSheet.test.tsx +88 -0
  124. package/src/dialog/CenteredDialog.tsx +2 -0
  125. package/src/dialog/CenteredDialog.web.tsx +2 -0
  126. package/src/dialog/Dialog.tsx +407 -50
  127. package/src/dialog/Dialog.web.tsx +490 -75
  128. package/src/dialog/centered-dialog-types.ts +4 -0
  129. package/src/dialog/index.ts +4 -1
  130. package/src/dialog/index.web.ts +4 -1
  131. package/src/dialog/placement.ts +113 -0
  132. package/src/dialog/types.ts +69 -3
  133. package/src/index.ts +12 -0
  134. package/src/index.web.ts +12 -0
  135. package/src/responsive-sheet/ResponsiveSheet.stories.tsx +54 -0
  136. package/src/responsive-sheet/ResponsiveSheet.tsx +88 -0
  137. package/src/responsive-sheet/ResponsiveSheet.web.tsx +76 -0
  138. package/src/responsive-sheet/index.ts +6 -0
  139. package/src/responsive-sheet/index.web.ts +14 -0
  140. package/src/responsive-sheet/types.ts +60 -0
@@ -1,7 +1,6 @@
1
1
  import React, {
2
2
  createContext,
3
3
  useCallback,
4
- useContext,
5
4
  useEffect,
6
5
  useId,
7
6
  useImperativeHandle,
@@ -13,8 +12,10 @@ import {
13
12
  Pressable,
14
13
  Text,
15
14
  TouchableOpacity,
15
+ useWindowDimensions,
16
16
  View,
17
17
  type GestureResponderEvent,
18
+ type StyleProp,
18
19
  type ViewStyle,
19
20
  } from 'react-native';
20
21
  import { RemoveScrollBar } from 'react-remove-scroll-bar';
@@ -24,14 +25,32 @@ import { createOverlayZIndex } from '../styles/z-index';
24
25
  import type { ThemeColors } from '../theme/types';
25
26
  import { useTheme } from '../theme/use-theme';
26
27
  import { Context, useDialogContext, useDialogControl } from './context';
28
+ import {
29
+ ANIMATION_DURATION,
30
+ CENTER_FADE_OUT_DURATION,
31
+ DEFAULT_CENTER_MAX_WIDTH,
32
+ DEFAULT_MAX_HEIGHT_RATIO,
33
+ DEFAULT_SIDE_WIDTH,
34
+ DIALOG_SHEET_BACKDROP_TESTID,
35
+ EASE_OUT,
36
+ HANDLE_HEIGHT,
37
+ HANDLE_RADIUS,
38
+ HANDLE_WIDTH,
39
+ PANEL_RADIUS,
40
+ SHEET_BACKDROP_OPACITY,
41
+ SIDE_SHEET_MIN_GUTTER,
42
+ useResolvedPlacement,
43
+ type DialogPlacement,
44
+ } from './placement';
27
45
  import type {
28
46
  DialogAction,
29
47
  DialogActionColor,
30
48
  DialogControlProps,
49
+ DialogInset,
31
50
  DialogProps,
32
51
  } from './types';
33
52
 
34
- const FADE_OUT_DURATION = 150;
53
+ const FADE_OUT_DURATION = CENTER_FADE_OUT_DURATION;
35
54
  const dialogZIndex = createOverlayZIndex();
36
55
 
37
56
  const stopPropagation = (e: { stopPropagation: () => void }) => e.stopPropagation();
@@ -41,42 +60,101 @@ const ClosingContext = createContext(false);
41
60
  /**
42
61
  * Web variant of `<Dialog>`.
43
62
  *
44
- * A centered modal card rendered into the bloom Portal at the end of
45
- * `document.body`. Same prop API as native`title`, `description`,
46
- * `actions`, or arbitrary `children` so call sites are platform
47
- * agnostic.
63
+ * A centered modal card (default) rendered into the bloom Portal at the end of
64
+ * `document.body`, or when `placement` is `left`/`right`/`bottom` an
65
+ * anchored side-sheet or bottom-sheet. Same prop API as native, so call sites
66
+ * are platform agnostic.
67
+ *
68
+ * Open/close is driven by EITHER the imperative `control` (legacy) OR the
69
+ * controlled `open` prop. When `open` is provided it wins.
48
70
  *
49
71
  * Accessibility: the panel has `role="dialog"` and the `title`/`description`
50
72
  * props (when provided) become the `aria-labelledby` / `aria-describedby`
51
73
  * targets. Pressing the backdrop dismisses. Pressing `Escape` dismisses.
52
74
  * Focus is locked inside the dialog while it's open.
75
+ *
76
+ * Web motion never uses reanimated `exiting` (which throws `removeChild` on
77
+ * concurrent React unmounts): the centered card uses CSS keyframes and the
78
+ * side/bottom modes use self-contained CSS transitions (the `responsive-sheet`
79
+ * technique) on a mounted-through-exit node.
53
80
  */
54
81
  export function Dialog({
55
82
  control,
83
+ open: controlledOpen,
56
84
  onClose,
57
85
  testID,
58
86
  title,
59
87
  description,
60
88
  actions,
89
+ placement,
90
+ width = DEFAULT_SIDE_WIDTH,
91
+ maxWidth = DEFAULT_CENTER_MAX_WIDTH,
92
+ maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
93
+ inset,
94
+ showHandle = true,
95
+ dismissOnBackdrop = true,
61
96
  style,
97
+ panelStyle,
98
+ panelClassName,
99
+ containerStyle,
100
+ containerClassName,
62
101
  label,
63
102
  children,
64
103
  }: DialogProps) {
104
+ // Controlled mode is opt-in: when `open` is a boolean the host owns the
105
+ // visible state; otherwise the legacy imperative `control` path drives it.
106
+ const isControlled = controlledOpen !== undefined;
107
+ const resolvedPlacement = useResolvedPlacement(placement);
108
+
65
109
  const [isOpen, setIsOpen] = useState(false);
66
110
  const [isClosing, setIsClosing] = useState(false);
67
111
  const closeCallbacksRef = useRef<(() => void)[]>([]);
112
+ // Read the latest controlled flag inside stable callbacks without re-binding.
113
+ const isControlledRef = useRef(isControlled);
114
+ isControlledRef.current = isControlled;
68
115
 
69
116
  const open = useCallback(() => {
70
117
  setIsClosing(false);
71
118
  setIsOpen(true);
72
119
  }, []);
73
120
 
74
- const close = useCallback<DialogControlProps['close']>((cb) => {
75
- if (typeof cb === 'function') {
76
- closeCallbacksRef.current.push(cb);
121
+ // A dismissal request (backdrop, Escape, action button). In controlled mode
122
+ // it asks the host to close via `onClose` (the host flips `open`); in
123
+ // imperative mode it starts the exit animation directly. Either way an
124
+ // optional callback runs after the dialog has finished closing.
125
+ const close = useCallback<DialogControlProps['close']>(
126
+ (cb) => {
127
+ if (typeof cb === 'function') {
128
+ closeCallbacksRef.current.push(cb);
129
+ }
130
+ if (isControlledRef.current) {
131
+ onCloseRef.current?.();
132
+ return;
133
+ }
134
+ setIsClosing(true);
135
+ },
136
+ [],
137
+ );
138
+
139
+ // `onClose` mirrored into a ref so `close` stays referentially stable (the
140
+ // dialog context + imperative handle depend on it).
141
+ const onCloseRef = useRef(onClose);
142
+ onCloseRef.current = onClose;
143
+
144
+ // In controlled mode, mirror the `open` prop into the internal open/closing
145
+ // state machine so both modes share the same exit-animation lifecycle.
146
+ useEffect(() => {
147
+ if (!isControlled) return;
148
+ if (controlledOpen) {
149
+ setIsClosing(false);
150
+ setIsOpen(true);
151
+ } else {
152
+ setIsClosing(true);
77
153
  }
78
- setIsClosing(true);
79
- }, []);
154
+ }, [isControlled, controlledOpen]);
155
+
156
+ const exitDuration =
157
+ resolvedPlacement === 'center' ? FADE_OUT_DURATION : ANIMATION_DURATION;
80
158
 
81
159
  useEffect(() => {
82
160
  if (!isClosing) return;
@@ -95,11 +173,14 @@ export function Dialog({
95
173
  }
96
174
  }
97
175
  }
98
- onClose?.();
99
- }, FADE_OUT_DURATION);
176
+ // Imperative mode fires `onClose` when the exit settles. Controlled mode
177
+ // already requested the close through `onClose` (the host then flipped
178
+ // `open`), so it must NOT fire it a second time here.
179
+ if (!isControlledRef.current) onCloseRef.current?.();
180
+ }, exitDuration);
100
181
 
101
182
  return () => clearTimeout(timer);
102
- }, [isClosing, onClose]);
183
+ }, [isClosing, exitDuration]);
103
184
 
104
185
  // Escape-to-close while open. The listener is intentionally scoped to the
105
186
  // open lifetime so stacked dialogs don't fight for the keydown — the
@@ -117,7 +198,7 @@ export function Dialog({
117
198
  }, [close, isOpen]);
118
199
 
119
200
  useImperativeHandle(
120
- control.ref,
201
+ control?.ref,
121
202
  () => ({ open, close }),
122
203
  [open, close],
123
204
  );
@@ -129,48 +210,142 @@ export function Dialog({
129
210
 
130
211
  if (!isOpen) return null;
131
212
 
213
+ if (resolvedPlacement === 'center') {
214
+ return (
215
+ <Portal>
216
+ <Context.Provider value={context}>
217
+ <ClosingContext.Provider value={isClosing}>
218
+ <RemoveScrollBar />
219
+ <Pressable
220
+ onPress={dismissOnBackdrop ? () => close() : undefined}
221
+ disabled={!dismissOnBackdrop}
222
+ // `pointerEvents: 'auto'` opts back in from the Portal root's
223
+ // `pointer-events: none`, which is set so the idle portal
224
+ // doesn't intercept clicks on the underlying app.
225
+ style={{
226
+ position: 'fixed' as 'absolute',
227
+ top: 0,
228
+ left: 0,
229
+ right: 0,
230
+ bottom: 0,
231
+ zIndex: dialogZIndex.backdrop,
232
+ alignItems: 'center',
233
+ justifyContent: 'center',
234
+ paddingHorizontal: 20,
235
+ pointerEvents: 'auto',
236
+ }}
237
+ >
238
+ <DialogBackdrop isClosing={isClosing} />
239
+ <DialogPanel
240
+ testID={testID}
241
+ label={label}
242
+ title={title}
243
+ description={description}
244
+ actions={actions}
245
+ style={style}
246
+ maxWidth={maxWidth}
247
+ isClosing={isClosing}
248
+ >
249
+ {children}
250
+ </DialogPanel>
251
+ </Pressable>
252
+ </ClosingContext.Provider>
253
+ </Context.Provider>
254
+ </Portal>
255
+ );
256
+ }
257
+
132
258
  return (
133
259
  <Portal>
134
260
  <Context.Provider value={context}>
135
261
  <ClosingContext.Provider value={isClosing}>
136
262
  <RemoveScrollBar />
137
- <Pressable
138
- onPress={() => close()}
139
- // `pointerEvents: 'auto'` opts back in from the Portal root's
140
- // `pointer-events: none`, which is set so the idle portal
141
- // doesn't intercept clicks on the underlying app.
142
- style={{
143
- position: 'fixed' as 'absolute',
144
- top: 0,
145
- left: 0,
146
- right: 0,
147
- bottom: 0,
148
- zIndex: dialogZIndex.backdrop,
149
- alignItems: 'center',
150
- justifyContent: 'center',
151
- paddingHorizontal: 20,
152
- pointerEvents: 'auto',
153
- }}
263
+ <SheetSurface
264
+ testID={testID}
265
+ label={label}
266
+ title={title}
267
+ description={description}
268
+ actions={actions}
269
+ placement={resolvedPlacement}
270
+ shown={!isClosing}
271
+ width={width}
272
+ maxHeightRatio={maxHeightRatio}
273
+ inset={inset}
274
+ showHandle={showHandle}
275
+ dismissOnBackdrop={dismissOnBackdrop}
276
+ onDismiss={close}
277
+ panelStyle={panelStyle}
278
+ panelClassName={panelClassName}
279
+ containerStyle={containerStyle}
280
+ containerClassName={containerClassName}
281
+ style={style}
154
282
  >
155
- <DialogBackdrop isClosing={isClosing} />
156
- <DialogPanel
157
- testID={testID}
158
- label={label}
159
- title={title}
160
- description={description}
161
- actions={actions}
162
- style={style}
163
- isClosing={isClosing}
164
- >
165
- {children}
166
- </DialogPanel>
167
- </Pressable>
283
+ {children}
284
+ </SheetSurface>
168
285
  </ClosingContext.Provider>
169
286
  </Context.Provider>
170
287
  </Portal>
171
288
  );
172
289
  }
173
290
 
291
+ /**
292
+ * Renders the dialog's body: optional declarative title + description, any
293
+ * `children`, then the action row. Shared by the centered panel and the
294
+ * side/bottom sheet so all placements present identical content. The
295
+ * `titleId`/`descriptionId` are wired by the caller onto the `role="dialog"`
296
+ * element for `aria-labelledby` / `aria-describedby`.
297
+ */
298
+ function DialogBody({
299
+ titleId,
300
+ descriptionId,
301
+ title,
302
+ description,
303
+ actions,
304
+ children,
305
+ }: {
306
+ titleId: string;
307
+ descriptionId: string;
308
+ title?: string;
309
+ description?: string;
310
+ actions?: DialogAction[];
311
+ children?: React.ReactNode;
312
+ }) {
313
+ const theme = useTheme();
314
+ return (
315
+ <>
316
+ {title ? (
317
+ <Text
318
+ nativeID={titleId}
319
+ style={{
320
+ fontSize: 22,
321
+ fontWeight: '600',
322
+ color: theme.colors.text,
323
+ paddingBottom: description ? 4 : 16,
324
+ lineHeight: 30,
325
+ }}
326
+ >
327
+ {title}
328
+ </Text>
329
+ ) : null}
330
+ {description ? (
331
+ <Text
332
+ nativeID={descriptionId}
333
+ style={{
334
+ fontSize: 16,
335
+ color: theme.colors.textSecondary,
336
+ paddingBottom: 16,
337
+ lineHeight: 22,
338
+ }}
339
+ >
340
+ {description}
341
+ </Text>
342
+ ) : null}
343
+ {children}
344
+ {actions && actions.length > 0 ? <ActionRow actions={actions} /> : null}
345
+ </>
346
+ );
347
+ }
348
+
174
349
  function DialogPanel({
175
350
  testID,
176
351
  label,
@@ -178,6 +353,7 @@ function DialogPanel({
178
353
  description,
179
354
  actions,
180
355
  style,
356
+ maxWidth,
181
357
  isClosing,
182
358
  children,
183
359
  }: {
@@ -187,6 +363,7 @@ function DialogPanel({
187
363
  description?: string;
188
364
  actions?: DialogAction[];
189
365
  style?: DialogProps['style'];
366
+ maxWidth: number;
190
367
  isClosing: boolean;
191
368
  children?: React.ReactNode;
192
369
  }) {
@@ -209,7 +386,7 @@ function DialogPanel({
209
386
  position: 'relative',
210
387
  borderRadius: 20,
211
388
  width: '100%',
212
- maxWidth: 480,
389
+ maxWidth,
213
390
  backgroundColor: theme.colors.background,
214
391
  borderWidth: 1,
215
392
  borderColor: theme.isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
@@ -226,39 +403,232 @@ function DialogPanel({
226
403
  style,
227
404
  ]}
228
405
  >
229
- {title ? (
230
- <Text
231
- nativeID={titleId}
232
- style={{
233
- fontSize: 22,
234
- fontWeight: '600',
235
- color: theme.colors.text,
236
- paddingBottom: description ? 4 : 16,
237
- lineHeight: 30,
238
- }}
239
- >
240
- {title}
241
- </Text>
242
- ) : null}
243
- {description ? (
244
- <Text
245
- nativeID={descriptionId}
246
- style={{
247
- fontSize: 16,
248
- color: theme.colors.textSecondary,
249
- paddingBottom: 16,
250
- lineHeight: 22,
251
- }}
252
- >
253
- {description}
254
- </Text>
255
- ) : null}
256
- {children}
257
- {actions && actions.length > 0 ? <ActionRow actions={actions} /> : null}
406
+ <DialogBody
407
+ titleId={titleId}
408
+ descriptionId={descriptionId}
409
+ title={title}
410
+ description={description}
411
+ actions={actions}
412
+ >
413
+ {children}
414
+ </DialogBody>
258
415
  </View>
259
416
  );
260
417
  }
261
418
 
419
+ export { DIALOG_SHEET_BACKDROP_TESTID };
420
+
421
+ /**
422
+ * Side-sheet / bottom-sheet surface for the `left`/`right`/`bottom`
423
+ * placements. Pure CSS transitions (the `responsive-sheet` technique): the
424
+ * node stays mounted through the exit while a `shown` flag drives the
425
+ * transform/opacity, so both directions animate without reanimated `exiting`.
426
+ */
427
+ function SheetSurface({
428
+ testID,
429
+ label,
430
+ title,
431
+ description,
432
+ actions,
433
+ placement,
434
+ shown,
435
+ width,
436
+ maxHeightRatio,
437
+ inset,
438
+ showHandle,
439
+ dismissOnBackdrop,
440
+ onDismiss,
441
+ panelStyle,
442
+ panelClassName,
443
+ containerStyle,
444
+ containerClassName,
445
+ style,
446
+ children,
447
+ }: {
448
+ testID?: string;
449
+ label?: string;
450
+ title?: string;
451
+ description?: string;
452
+ actions?: DialogAction[];
453
+ placement: Exclude<DialogPlacement, 'center'>;
454
+ shown: boolean;
455
+ width: number;
456
+ maxHeightRatio: number;
457
+ inset?: DialogInset;
458
+ showHandle: boolean;
459
+ dismissOnBackdrop: boolean;
460
+ onDismiss: () => void;
461
+ panelStyle?: StyleProp<ViewStyle>;
462
+ panelClassName?: string;
463
+ containerStyle?: StyleProp<ViewStyle>;
464
+ containerClassName?: string;
465
+ style?: StyleProp<ViewStyle>;
466
+ children?: React.ReactNode;
467
+ }) {
468
+ const theme = useTheme();
469
+ const titleId = useId();
470
+ const descriptionId = useId();
471
+ const { width: viewportWidth, height: viewportHeight } = useWindowDimensions();
472
+
473
+ // Defer the entry transition by a frame so the start state paints before the
474
+ // browser animates to `shown`. `entered` is false on the first committed
475
+ // frame, then flips true so the CSS transition runs.
476
+ const frameRef = useRef<FrameToken | null>(null);
477
+ const [entered, setEntered] = useState(false);
478
+ useEffect(() => {
479
+ frameRef.current = scheduleFrame(() => {
480
+ frameRef.current = scheduleFrame(() => setEntered(true));
481
+ });
482
+ return () => {
483
+ if (frameRef.current !== null) cancelFrame(frameRef.current);
484
+ frameRef.current = null;
485
+ };
486
+ }, []);
487
+
488
+ const visible = shown && entered;
489
+ const isBottom = placement === 'bottom';
490
+
491
+ const panelTransition = useMemo<ViewStyle>(
492
+ () =>
493
+ ({
494
+ transitionProperty: 'transform, opacity',
495
+ transitionDuration: `${ANIMATION_DURATION}ms`,
496
+ transitionTimingFunction: EASE_OUT,
497
+ }) as ViewStyle,
498
+ [],
499
+ );
500
+
501
+ const backdropTransition = useMemo<ViewStyle>(
502
+ () =>
503
+ ({
504
+ transitionProperty: 'opacity',
505
+ transitionDuration: `${ANIMATION_DURATION}ms`,
506
+ transitionTimingFunction: EASE_OUT,
507
+ }) as ViewStyle,
508
+ [],
509
+ );
510
+
511
+ const panelGeometry = useMemo<ViewStyle>(() => {
512
+ if (isBottom) {
513
+ return {
514
+ left: 0,
515
+ right: 0,
516
+ bottom: 0,
517
+ maxHeight: Math.round(viewportHeight * maxHeightRatio),
518
+ borderTopLeftRadius: PANEL_RADIUS,
519
+ borderTopRightRadius: PANEL_RADIUS,
520
+ transform: [{ translateY: visible ? 0 : '100%' }],
521
+ opacity: visible ? 1 : 0,
522
+ } as ViewStyle;
523
+ }
524
+
525
+ const insetTop = inset?.top ?? 0;
526
+ const insetBottom = inset?.bottom ?? 0;
527
+ const insetLeft = inset?.left ?? 0;
528
+ const insetRight = inset?.right ?? 0;
529
+ const anchorInset = placement === 'left' ? insetLeft : insetRight;
530
+ const oppositeInset = placement === 'left' ? insetRight : insetLeft;
531
+ const available = viewportWidth - anchorInset - oppositeInset - SIDE_SHEET_MIN_GUTTER;
532
+ const cappedWidth = Math.max(0, Math.min(width, available));
533
+ const hiddenSign = placement === 'left' ? '-100%' : '100%';
534
+
535
+ return {
536
+ top: insetTop,
537
+ bottom: insetBottom,
538
+ [placement]: anchorInset,
539
+ width: cappedWidth,
540
+ borderRadius: PANEL_RADIUS,
541
+ transform: [{ translateX: visible ? 0 : hiddenSign }],
542
+ opacity: visible ? 1 : 0,
543
+ } as ViewStyle;
544
+ }, [isBottom, visible, placement, width, inset, viewportWidth, viewportHeight, maxHeightRatio]);
545
+
546
+ const handleBackdropPress = useCallback(() => {
547
+ if (dismissOnBackdrop) onDismiss();
548
+ }, [dismissOnBackdrop, onDismiss]);
549
+
550
+ return (
551
+ <View
552
+ style={[sheetStyles.root, containerStyle]}
553
+ {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
554
+ pointerEvents="box-none"
555
+ >
556
+ <Pressable
557
+ testID={testID ? `${testID}-backdrop` : DIALOG_SHEET_BACKDROP_TESTID}
558
+ accessibilityRole="button"
559
+ accessibilityLabel={label ? `Dismiss ${label}` : 'Dismiss dialog'}
560
+ onPress={handleBackdropPress}
561
+ disabled={!dismissOnBackdrop}
562
+ style={[
563
+ sheetStyles.backdrop,
564
+ backdropTransition,
565
+ { opacity: visible ? SHEET_BACKDROP_OPACITY : 0 },
566
+ ]}
567
+ />
568
+
569
+ <View
570
+ role="dialog"
571
+ aria-label={label}
572
+ aria-labelledby={title ? titleId : undefined}
573
+ aria-describedby={description ? descriptionId : undefined}
574
+ accessibilityViewIsModal
575
+ testID={testID}
576
+ onStartShouldSetResponder={() => true}
577
+ onResponderRelease={stopPropagation}
578
+ {...({ onClick: stopPropagation } as Record<string, unknown>)}
579
+ {...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
580
+ style={[
581
+ sheetStyles.panel,
582
+ { backgroundColor: theme.colors.background, shadowColor: theme.colors.shadow },
583
+ panelGeometry,
584
+ panelTransition,
585
+ panelStyle,
586
+ style,
587
+ ]}
588
+ pointerEvents="auto"
589
+ >
590
+ {isBottom && showHandle ? (
591
+ <View style={sheetStyles.handleRow} pointerEvents="none">
592
+ <View style={[sheetStyles.handle, { backgroundColor: theme.colors.border }]} />
593
+ </View>
594
+ ) : null}
595
+ <View style={sheetStyles.body}>
596
+ <DialogBody
597
+ titleId={titleId}
598
+ descriptionId={descriptionId}
599
+ title={title}
600
+ description={description}
601
+ actions={actions}
602
+ >
603
+ {children}
604
+ </DialogBody>
605
+ </View>
606
+ </View>
607
+ </View>
608
+ );
609
+ }
610
+
611
+ /**
612
+ * Opaque token returned by `scheduleFrame`. `requestAnimationFrame` returns a
613
+ * number; the `setTimeout` fallback returns a timer handle.
614
+ */
615
+ type FrameToken = { raf: number } | { timer: ReturnType<typeof setTimeout> };
616
+
617
+ function scheduleFrame(cb: () => void): FrameToken {
618
+ if (typeof requestAnimationFrame === 'function') {
619
+ return { raf: requestAnimationFrame(cb) };
620
+ }
621
+ return { timer: setTimeout(cb, 0) };
622
+ }
623
+
624
+ function cancelFrame(token: FrameToken): void {
625
+ if ('raf' in token) {
626
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(token.raf);
627
+ return;
628
+ }
629
+ clearTimeout(token.timer);
630
+ }
631
+
262
632
  function ActionRow({ actions }: { actions: DialogAction[] }) {
263
633
  return (
264
634
  <View style={{ width: '100%', gap: 8, justifyContent: 'flex-end' }}>
@@ -392,6 +762,48 @@ export function AutoMountedDialog({
392
762
  );
393
763
  }
394
764
 
765
+ const sheetStyles = {
766
+ root: {
767
+ position: 'fixed' as 'absolute',
768
+ top: 0,
769
+ left: 0,
770
+ right: 0,
771
+ bottom: 0,
772
+ zIndex: dialogZIndex.backdrop,
773
+ } as ViewStyle,
774
+ backdrop: {
775
+ position: 'absolute',
776
+ top: 0,
777
+ left: 0,
778
+ right: 0,
779
+ bottom: 0,
780
+ backgroundColor: '#000',
781
+ } as ViewStyle,
782
+ panel: {
783
+ position: 'absolute',
784
+ overflow: 'hidden',
785
+ shadowOpacity: 0.18,
786
+ shadowRadius: 24,
787
+ shadowOffset: { width: 0, height: 8 },
788
+ zIndex: dialogZIndex.surface,
789
+ } as ViewStyle,
790
+ handleRow: {
791
+ width: '100%',
792
+ alignItems: 'center',
793
+ paddingTop: 8,
794
+ paddingBottom: 4,
795
+ } as ViewStyle,
796
+ handle: {
797
+ width: HANDLE_WIDTH,
798
+ height: HANDLE_HEIGHT,
799
+ borderRadius: HANDLE_RADIUS,
800
+ opacity: 0.5,
801
+ } as ViewStyle,
802
+ body: {
803
+ padding: 20,
804
+ } as ViewStyle,
805
+ };
806
+
395
807
  /**
396
808
  * CSS keyframes required for web dialog animations.
397
809
  * Consumers should inject this string into a <style> tag or their global CSS:
@@ -402,6 +814,9 @@ export function AutoMountedDialog({
402
814
  * @keyframes bloomDialogZoomFadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
403
815
  * @keyframes bloomDialogZoomFadeOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
404
816
  * ```
817
+ *
818
+ * The `left`/`right`/`bottom` placements do NOT depend on this — they animate
819
+ * via self-contained inline CSS transitions and need no keyframe injection.
405
820
  */
406
821
  export const BLOOM_DIALOG_CSS = `
407
822
  @keyframes bloomDialogFadeIn { from { opacity: 0; } to { opacity: 1; } }