@oxyhq/bloom 0.16.0 → 0.16.2

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 (53) hide show
  1. package/lib/commonjs/dialog/Dialog.js +45 -157
  2. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  3. package/lib/commonjs/dialog/Dialog.web.js +64 -198
  4. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  5. package/lib/commonjs/dialog/DialogBottomSheet.js +216 -0
  6. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -0
  7. package/lib/commonjs/dialog/DialogContent.js +155 -0
  8. package/lib/commonjs/dialog/DialogContent.js.map +1 -0
  9. package/lib/commonjs/dialog/placement.js +12 -1
  10. package/lib/commonjs/dialog/placement.js.map +1 -1
  11. package/lib/module/dialog/Dialog.js +48 -160
  12. package/lib/module/dialog/Dialog.js.map +1 -1
  13. package/lib/module/dialog/Dialog.web.js +66 -200
  14. package/lib/module/dialog/Dialog.web.js.map +1 -1
  15. package/lib/module/dialog/DialogBottomSheet.js +211 -0
  16. package/lib/module/dialog/DialogBottomSheet.js.map +1 -0
  17. package/lib/module/dialog/DialogContent.js +148 -0
  18. package/lib/module/dialog/DialogContent.js.map +1 -0
  19. package/lib/module/dialog/placement.js +11 -0
  20. package/lib/module/dialog/placement.js.map +1 -1
  21. package/lib/typescript/commonjs/dialog/Dialog.d.ts +1 -1
  22. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +11 -8
  24. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +29 -0
  26. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -0
  27. package/lib/typescript/commonjs/dialog/DialogContent.d.ts +32 -0
  28. package/lib/typescript/commonjs/dialog/DialogContent.d.ts.map +1 -0
  29. package/lib/typescript/commonjs/dialog/placement.d.ts +10 -0
  30. package/lib/typescript/commonjs/dialog/placement.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/dialog/types.d.ts +12 -0
  32. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  33. package/lib/typescript/module/dialog/Dialog.d.ts +1 -1
  34. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  35. package/lib/typescript/module/dialog/Dialog.web.d.ts +11 -8
  36. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  37. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +29 -0
  38. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -0
  39. package/lib/typescript/module/dialog/DialogContent.d.ts +32 -0
  40. package/lib/typescript/module/dialog/DialogContent.d.ts.map +1 -0
  41. package/lib/typescript/module/dialog/placement.d.ts +10 -0
  42. package/lib/typescript/module/dialog/placement.d.ts.map +1 -1
  43. package/lib/typescript/module/dialog/types.d.ts +12 -0
  44. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/__tests__/BottomSheetGesture.test.tsx +135 -0
  47. package/src/__tests__/DialogBottomSheet.test.tsx +259 -0
  48. package/src/dialog/Dialog.tsx +41 -171
  49. package/src/dialog/Dialog.web.tsx +56 -211
  50. package/src/dialog/DialogBottomSheet.tsx +250 -0
  51. package/src/dialog/DialogContent.tsx +157 -0
  52. package/src/dialog/placement.ts +11 -0
  53. package/src/dialog/types.ts +12 -0
@@ -10,11 +10,8 @@ import React, {
10
10
  } from 'react';
11
11
  import {
12
12
  Pressable,
13
- Text,
14
- TouchableOpacity,
15
13
  useWindowDimensions,
16
14
  View,
17
- type GestureResponderEvent,
18
15
  type StyleProp,
19
16
  type ViewStyle,
20
17
  } from 'react-native';
@@ -22,29 +19,25 @@ import { RemoveScrollBar } from 'react-remove-scroll-bar';
22
19
 
23
20
  import { Portal } from '../portal/index.web';
24
21
  import { createOverlayZIndex } from '../styles/z-index';
25
- import type { ThemeColors } from '../theme/types';
26
22
  import { useTheme } from '../theme/use-theme';
27
- import { Context, useDialogContext, useDialogControl } from './context';
23
+ import { Context, useDialogControl } from './context';
24
+ import { DialogBody } from './DialogContent';
25
+ import { DialogBottomSheet } from './DialogBottomSheet';
28
26
  import {
29
27
  ANIMATION_DURATION,
30
28
  CENTER_FADE_OUT_DURATION,
31
29
  DEFAULT_CENTER_MAX_WIDTH,
32
- DEFAULT_MAX_HEIGHT_RATIO,
30
+ DEFAULT_DIALOG_CONTENT_PADDING,
33
31
  DEFAULT_SIDE_WIDTH,
34
32
  DIALOG_SHEET_BACKDROP_TESTID,
35
33
  EASE_OUT,
36
- HANDLE_HEIGHT,
37
- HANDLE_RADIUS,
38
- HANDLE_WIDTH,
39
34
  PANEL_RADIUS,
40
35
  SHEET_BACKDROP_OPACITY,
41
36
  SIDE_SHEET_MIN_GUTTER,
42
37
  useResolvedPlacement,
43
- type DialogPlacement,
44
38
  } from './placement';
45
39
  import type {
46
40
  DialogAction,
47
- DialogActionColor,
48
41
  DialogControlProps,
49
42
  DialogInset,
50
43
  DialogProps,
@@ -61,9 +54,10 @@ const ClosingContext = createContext(false);
61
54
  * Web variant of `<Dialog>`.
62
55
  *
63
56
  * 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.
57
+ * `document.body`, an anchored side-sheet for `left`/`right`, or for
58
+ * `bottom` bloom's cross-platform `BottomSheet` (the SAME component native
59
+ * uses), so the bottom placement drags-to-dismiss on web too. Same prop API as
60
+ * native, so call sites are platform agnostic.
67
61
  *
68
62
  * Open/close is driven by EITHER the imperative `control` (legacy) OR the
69
63
  * controlled `open` prop. When `open` is provided it wins.
@@ -75,10 +69,32 @@ const ClosingContext = createContext(false);
75
69
  *
76
70
  * Web motion never uses reanimated `exiting` (which throws `removeChild` on
77
71
  * concurrent React unmounts): the centered card uses CSS keyframes and the
78
- * side/bottom modes use self-contained CSS transitions on a
79
- * mounted-through-exit node.
72
+ * side modes use self-contained CSS transitions on a mounted-through-exit
73
+ * node. The bottom placement animates via `BottomSheet`'s shared-value/gesture
74
+ * animation (no `exiting` layout animation).
80
75
  */
81
- export function Dialog({
76
+ export function Dialog({ placement, ...rest }: DialogProps) {
77
+ const resolvedPlacement = useResolvedPlacement(placement);
78
+
79
+ // `bottom` routes to the shared cross-platform `BottomSheet` surface — the
80
+ // SAME component native uses — so drag-to-dismiss works on web too and there
81
+ // is no duplicated CSS slide-up sheet. `center` (default) and the side-sheets
82
+ // keep their DOM-portal implementation below. Each branch is its own
83
+ // component so the dispatcher only ever calls `useResolvedPlacement`, keeping
84
+ // the rules-of-hooks contract intact across a responsive placement change.
85
+ if (resolvedPlacement === 'bottom') {
86
+ return <DialogBottomSheet {...rest} />;
87
+ }
88
+ return <CenterOrSideDialog {...rest} placement={resolvedPlacement} />;
89
+ }
90
+
91
+ /**
92
+ * Web `center` (default) and `left`/`right` placements. The centered card is a
93
+ * DOM-portal modal; the side placements are CSS-transition sheets. The `bottom`
94
+ * placement is handled separately by `DialogBottomSheet` (bloom's cross-platform
95
+ * `BottomSheet`).
96
+ */
97
+ function CenterOrSideDialog({
82
98
  control,
83
99
  open: controlledOpen,
84
100
  onClose,
@@ -89,10 +105,9 @@ export function Dialog({
89
105
  placement,
90
106
  width = DEFAULT_SIDE_WIDTH,
91
107
  maxWidth = DEFAULT_CENTER_MAX_WIDTH,
92
- maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
93
108
  inset,
94
- showHandle = true,
95
109
  dismissOnBackdrop = true,
110
+ contentPadding = DEFAULT_DIALOG_CONTENT_PADDING,
96
111
  style,
97
112
  panelStyle,
98
113
  panelClassName,
@@ -100,11 +115,11 @@ export function Dialog({
100
115
  containerClassName,
101
116
  label,
102
117
  children,
103
- }: DialogProps) {
118
+ }: Omit<DialogProps, 'placement'> & { placement: 'center' | 'left' | 'right' }) {
104
119
  // Controlled mode is opt-in: when `open` is a boolean the host owns the
105
120
  // visible state; otherwise the legacy imperative `control` path drives it.
106
121
  const isControlled = controlledOpen !== undefined;
107
- const resolvedPlacement = useResolvedPlacement(placement);
122
+ const resolvedPlacement = placement;
108
123
 
109
124
  const [isOpen, setIsOpen] = useState(false);
110
125
  const [isClosing, setIsClosing] = useState(false);
@@ -247,6 +262,7 @@ export function Dialog({
247
262
  actions={actions}
248
263
  style={style}
249
264
  maxWidth={maxWidth}
265
+ contentPadding={contentPadding}
250
266
  isClosing={isClosing}
251
267
  >
252
268
  {children}
@@ -272,10 +288,9 @@ export function Dialog({
272
288
  placement={resolvedPlacement}
273
289
  shown={!isClosing}
274
290
  width={width}
275
- maxHeightRatio={maxHeightRatio}
276
291
  inset={inset}
277
- showHandle={showHandle}
278
292
  dismissOnBackdrop={dismissOnBackdrop}
293
+ contentPadding={contentPadding}
279
294
  onDismiss={close}
280
295
  panelStyle={panelStyle}
281
296
  panelClassName={panelClassName}
@@ -291,64 +306,6 @@ export function Dialog({
291
306
  );
292
307
  }
293
308
 
294
- /**
295
- * Renders the dialog's body: optional declarative title + description, any
296
- * `children`, then the action row. Shared by the centered panel and the
297
- * side/bottom sheet so all placements present identical content. The
298
- * `titleId`/`descriptionId` are wired by the caller onto the `role="dialog"`
299
- * element for `aria-labelledby` / `aria-describedby`.
300
- */
301
- function DialogBody({
302
- titleId,
303
- descriptionId,
304
- title,
305
- description,
306
- actions,
307
- children,
308
- }: {
309
- titleId: string;
310
- descriptionId: string;
311
- title?: string;
312
- description?: string;
313
- actions?: DialogAction[];
314
- children?: React.ReactNode;
315
- }) {
316
- const theme = useTheme();
317
- return (
318
- <>
319
- {title ? (
320
- <Text
321
- nativeID={titleId}
322
- style={{
323
- fontSize: 22,
324
- fontWeight: '600',
325
- color: theme.colors.text,
326
- paddingBottom: description ? 4 : 16,
327
- lineHeight: 30,
328
- }}
329
- >
330
- {title}
331
- </Text>
332
- ) : null}
333
- {description ? (
334
- <Text
335
- nativeID={descriptionId}
336
- style={{
337
- fontSize: 16,
338
- color: theme.colors.textSecondary,
339
- paddingBottom: 16,
340
- lineHeight: 22,
341
- }}
342
- >
343
- {description}
344
- </Text>
345
- ) : null}
346
- {children}
347
- {actions && actions.length > 0 ? <ActionRow actions={actions} /> : null}
348
- </>
349
- );
350
- }
351
-
352
309
  function DialogPanel({
353
310
  testID,
354
311
  label,
@@ -357,6 +314,7 @@ function DialogPanel({
357
314
  actions,
358
315
  style,
359
316
  maxWidth,
317
+ contentPadding,
360
318
  isClosing,
361
319
  children,
362
320
  }: {
@@ -367,6 +325,7 @@ function DialogPanel({
367
325
  actions?: DialogAction[];
368
326
  style?: DialogProps['style'];
369
327
  maxWidth: number;
328
+ contentPadding: number;
370
329
  isClosing: boolean;
371
330
  children?: React.ReactNode;
372
331
  }) {
@@ -397,7 +356,7 @@ function DialogPanel({
397
356
  shadowOpacity: theme.isDark ? 0.4 : 0.1,
398
357
  shadowRadius: 30,
399
358
  shadowOffset: { width: 0, height: 4 },
400
- padding: 20,
359
+ padding: contentPadding,
401
360
  zIndex: dialogZIndex.surface,
402
361
  },
403
362
  isClosing
@@ -422,10 +381,13 @@ function DialogPanel({
422
381
  export { DIALOG_SHEET_BACKDROP_TESTID };
423
382
 
424
383
  /**
425
- * Side-sheet / bottom-sheet surface for the `left`/`right`/`bottom`
426
- * placements. Pure CSS transitions: the
427
- * node stays mounted through the exit while a `shown` flag drives the
384
+ * Side-sheet surface for the `left`/`right` placements. Pure CSS transitions:
385
+ * the node stays mounted through the exit while a `shown` flag drives the
428
386
  * transform/opacity, so both directions animate without reanimated `exiting`.
387
+ *
388
+ * The `bottom` placement is NOT handled here — it routes through
389
+ * `DialogBottomSheet` (bloom's cross-platform `BottomSheet`) so it shares one
390
+ * implementation with native and supports drag-to-dismiss on web.
429
391
  */
430
392
  function SheetSurface({
431
393
  testID,
@@ -436,10 +398,9 @@ function SheetSurface({
436
398
  placement,
437
399
  shown,
438
400
  width,
439
- maxHeightRatio,
440
401
  inset,
441
- showHandle,
442
402
  dismissOnBackdrop,
403
+ contentPadding,
443
404
  onDismiss,
444
405
  panelStyle,
445
406
  panelClassName,
@@ -453,13 +414,12 @@ function SheetSurface({
453
414
  title?: string;
454
415
  description?: string;
455
416
  actions?: DialogAction[];
456
- placement: Exclude<DialogPlacement, 'center'>;
417
+ placement: 'left' | 'right';
457
418
  shown: boolean;
458
419
  width: number;
459
- maxHeightRatio: number;
460
420
  inset?: DialogInset;
461
- showHandle: boolean;
462
421
  dismissOnBackdrop: boolean;
422
+ contentPadding: number;
463
423
  onDismiss: () => void;
464
424
  panelStyle?: StyleProp<ViewStyle>;
465
425
  panelClassName?: string;
@@ -471,7 +431,7 @@ function SheetSurface({
471
431
  const theme = useTheme();
472
432
  const titleId = useId();
473
433
  const descriptionId = useId();
474
- const { width: viewportWidth, height: viewportHeight } = useWindowDimensions();
434
+ const { width: viewportWidth } = useWindowDimensions();
475
435
 
476
436
  // Defer the entry transition by a frame so the start state paints before the
477
437
  // browser animates to `shown`. `entered` is false on the first committed
@@ -489,7 +449,6 @@ function SheetSurface({
489
449
  }, []);
490
450
 
491
451
  const visible = shown && entered;
492
- const isBottom = placement === 'bottom';
493
452
 
494
453
  const panelTransition = useMemo<ViewStyle>(
495
454
  () =>
@@ -512,19 +471,6 @@ function SheetSurface({
512
471
  );
513
472
 
514
473
  const panelGeometry = useMemo<ViewStyle>(() => {
515
- if (isBottom) {
516
- return {
517
- left: 0,
518
- right: 0,
519
- bottom: 0,
520
- maxHeight: Math.round(viewportHeight * maxHeightRatio),
521
- borderTopLeftRadius: PANEL_RADIUS,
522
- borderTopRightRadius: PANEL_RADIUS,
523
- transform: [{ translateY: visible ? 0 : '100%' }],
524
- opacity: visible ? 1 : 0,
525
- } as ViewStyle;
526
- }
527
-
528
474
  const insetTop = inset?.top ?? 0;
529
475
  const insetBottom = inset?.bottom ?? 0;
530
476
  const insetLeft = inset?.left ?? 0;
@@ -544,7 +490,7 @@ function SheetSurface({
544
490
  transform: [{ translateX: visible ? 0 : hiddenSign }],
545
491
  opacity: visible ? 1 : 0,
546
492
  } as ViewStyle;
547
- }, [isBottom, visible, placement, width, inset, viewportWidth, viewportHeight, maxHeightRatio]);
493
+ }, [visible, placement, width, inset, viewportWidth]);
548
494
 
549
495
  const handleBackdropPress = useCallback(() => {
550
496
  if (dismissOnBackdrop) onDismiss();
@@ -590,12 +536,7 @@ function SheetSurface({
590
536
  ]}
591
537
  pointerEvents="auto"
592
538
  >
593
- {isBottom && showHandle ? (
594
- <View style={sheetStyles.handleRow} pointerEvents="none">
595
- <View style={[sheetStyles.handle, { backgroundColor: theme.colors.border }]} />
596
- </View>
597
- ) : null}
598
- <View style={sheetStyles.body}>
539
+ <View style={{ padding: contentPadding }}>
599
540
  <DialogBody
600
541
  titleId={titleId}
601
542
  descriptionId={descriptionId}
@@ -632,88 +573,6 @@ function cancelFrame(token: FrameToken): void {
632
573
  clearTimeout(token.timer);
633
574
  }
634
575
 
635
- function ActionRow({ actions }: { actions: DialogAction[] }) {
636
- return (
637
- <View style={{ width: '100%', gap: 8, justifyContent: 'flex-end' }}>
638
- {actions.map((action, idx) => (
639
- <ActionButton
640
- key={`${action.label}-${idx}`}
641
- action={action}
642
- />
643
- ))}
644
- </View>
645
- );
646
- }
647
-
648
- function ActionButton({ action }: { action: DialogAction }) {
649
- const { close } = useDialogContext();
650
- const theme = useTheme();
651
- const color: DialogActionColor = action.color ?? 'default';
652
- const shouldCloseOnPress = action.shouldCloseOnPress ?? true;
653
-
654
- const { background, foreground } = getActionPalette(color, theme.colors);
655
-
656
- const handlePress = useCallback(
657
- (e: GestureResponderEvent) => {
658
- const onPress = action.onPress;
659
- if (color === 'cancel') {
660
- close(onPress ? () => onPress(e) : undefined);
661
- return;
662
- }
663
- if (shouldCloseOnPress) {
664
- close(onPress ? () => onPress(e) : undefined);
665
- } else {
666
- onPress?.(e);
667
- }
668
- },
669
- [action.onPress, close, color, shouldCloseOnPress],
670
- );
671
-
672
- return (
673
- <TouchableOpacity
674
- style={{
675
- borderRadius: 9999,
676
- alignItems: 'center',
677
- justifyContent: 'center',
678
- backgroundColor: background,
679
- opacity: action.disabled ? 0.5 : 1,
680
- paddingVertical: 12,
681
- paddingHorizontal: 24,
682
- }}
683
- onPress={handlePress}
684
- disabled={action.disabled}
685
- activeOpacity={0.7}
686
- testID={action.testID}
687
- >
688
- <Text style={{ fontSize: 16, fontWeight: '500', color: foreground }}>
689
- {action.label}
690
- </Text>
691
- </TouchableOpacity>
692
- );
693
- }
694
-
695
- function getActionPalette(
696
- color: DialogActionColor,
697
- colors: ThemeColors,
698
- ): { background: string; foreground: string } {
699
- switch (color) {
700
- case 'destructive':
701
- return {
702
- background: colors.negative,
703
- foreground: colors.negativeForeground,
704
- };
705
- case 'cancel':
706
- return { background: colors.contrast50, foreground: colors.text };
707
- case 'default':
708
- return { background: colors.primary, foreground: colors.primaryForeground };
709
- /* c8 ignore next 3 -- TS exhaustiveness guard */
710
- default: {
711
- const _exhaustive: never = color;
712
- return { background: colors.primary, foreground: colors.primaryForeground };
713
- }
714
- }
715
- }
716
-
717
576
  function DialogBackdrop({ isClosing }: { isClosing: boolean }) {
718
577
  const style: ViewStyle[] = [
719
578
  {
@@ -790,21 +649,6 @@ const sheetStyles = {
790
649
  shadowOffset: { width: 0, height: 8 },
791
650
  zIndex: dialogZIndex.surface,
792
651
  } as ViewStyle,
793
- handleRow: {
794
- width: '100%',
795
- alignItems: 'center',
796
- paddingTop: 8,
797
- paddingBottom: 4,
798
- } as ViewStyle,
799
- handle: {
800
- width: HANDLE_WIDTH,
801
- height: HANDLE_HEIGHT,
802
- borderRadius: HANDLE_RADIUS,
803
- opacity: 0.5,
804
- } as ViewStyle,
805
- body: {
806
- padding: 20,
807
- } as ViewStyle,
808
652
  };
809
653
 
810
654
  /**
@@ -818,8 +662,9 @@ const sheetStyles = {
818
662
  * @keyframes bloomDialogZoomFadeOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
819
663
  * ```
820
664
  *
821
- * The `left`/`right`/`bottom` placements do NOT depend on this — they animate
822
- * via self-contained inline CSS transitions and need no keyframe injection.
665
+ * The `left`/`right` placements do NOT depend on this — they animate via
666
+ * self-contained inline CSS transitions and need no keyframe injection. The
667
+ * `bottom` placement uses bloom's `BottomSheet` (reanimated) and needs none.
823
668
  */
824
669
  export const BLOOM_DIALOG_CSS = `
825
670
  @keyframes bloomDialogFadeIn { from { opacity: 0; } to { opacity: 1; } }
@@ -0,0 +1,250 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ } from 'react';
9
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
10
+
11
+ import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
12
+ import { useTheme } from '../theme/use-theme';
13
+ import { Context } from './context';
14
+ import { DialogBody } from './DialogContent';
15
+ import {
16
+ DEFAULT_DIALOG_CONTENT_PADDING,
17
+ DEFAULT_MAX_HEIGHT_RATIO,
18
+ PANEL_RADIUS,
19
+ SHEET_BACKDROP_OPACITY,
20
+ } from './placement';
21
+ import type { DialogControlProps, DialogProps } from './types';
22
+
23
+ /**
24
+ * Shared `BottomSheet`-backed surface for the `bottom` placement, used by BOTH
25
+ * `Dialog.tsx` (native) and `Dialog.web.tsx` (web). `BottomSheet` is itself a
26
+ * single cross-platform implementation (react-native-gesture-handler +
27
+ * reanimated, drag-to-dismiss on web and native alike), so routing the bottom
28
+ * placement through here gives ONE code path with drag working everywhere — no
29
+ * duplicated CSS slide-up sheet.
30
+ *
31
+ * This component owns the entire open↔present/dismiss bridge and the Dialog →
32
+ * BottomSheet prop mapping in one place:
33
+ *
34
+ * - `open` (controlled) / `control` (imperative) → `present()` / `dismiss()`
35
+ * - `onClose` → fired once the sheet settles
36
+ * - `maxHeightRatio` → sheet `maxHeight`
37
+ * - `showHandle` / `dismissOnBackdrop` / `label` → forwarded to the surface
38
+ * - `panelStyle` → paints the sheet surface
39
+ * - `containerStyle` → wraps the content subtree
40
+ * so a host's CSS-var theme scope (`vars()`) still applies to descendants
41
+ * - `title` / `description` / `actions` / `children` → declarative body
42
+ */
43
+ export function DialogBottomSheet({
44
+ control,
45
+ open: controlledOpen,
46
+ onClose,
47
+ testID,
48
+ title,
49
+ description,
50
+ actions,
51
+ maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
52
+ showHandle = true,
53
+ dismissOnBackdrop = true,
54
+ contentPadding = DEFAULT_DIALOG_CONTENT_PADDING,
55
+ style,
56
+ panelStyle,
57
+ panelClassName,
58
+ containerStyle,
59
+ containerClassName,
60
+ label,
61
+ children,
62
+ }: DialogBottomSheetProps) {
63
+ const isControlled = controlledOpen !== undefined;
64
+ const theme = useTheme();
65
+ const ref = useRef<BottomSheetRef>(null);
66
+ const closeCallbacks = useRef<(() => void)[]>([]);
67
+ const titleId = useId();
68
+ const descriptionId = useId();
69
+
70
+ // Read the latest controlled flag / `onClose` inside stable callbacks without
71
+ // re-binding them (the context + imperative handle depend on `close` staying
72
+ // referentially stable).
73
+ const isControlledRef = useRef(isControlled);
74
+ isControlledRef.current = isControlled;
75
+ const onCloseRef = useRef(onClose);
76
+ onCloseRef.current = onClose;
77
+
78
+ // Drain queued close callbacks atomically — capturing the list and resetting
79
+ // it before invocation ensures a callback that synchronously re-opens the
80
+ // dialog (and queues fresh callbacks) does not see the old ones replayed
81
+ // against the new session.
82
+ const callQueuedCallbacks = useCallback(() => {
83
+ const queued = closeCallbacks.current;
84
+ closeCallbacks.current = [];
85
+ for (const cb of queued) {
86
+ try {
87
+ cb();
88
+ } catch (e) {
89
+ if (typeof console !== 'undefined' && console.error) {
90
+ console.error('Dialog close callback error:', e);
91
+ }
92
+ }
93
+ }
94
+ }, []);
95
+
96
+ const open = useCallback(() => {
97
+ ref.current?.present();
98
+ }, []);
99
+
100
+ // A dismissal request (backdrop, pan-to-close, action button). In controlled
101
+ // mode it asks the host to close via `onClose` (the host flips `open`); in
102
+ // imperative mode it dismisses the surface directly. The optional callback
103
+ // runs once the dialog has finished closing.
104
+ const close = useCallback<DialogControlProps['close']>((cb) => {
105
+ if (typeof cb === 'function') {
106
+ closeCallbacks.current.push(cb);
107
+ }
108
+ if (isControlledRef.current) {
109
+ onCloseRef.current?.();
110
+ return;
111
+ }
112
+ ref.current?.dismiss();
113
+ }, []);
114
+
115
+ // Fired when the sheet has finished closing. Drains queued callbacks, then
116
+ // fires `onClose` ONLY in imperative mode — controlled mode already requested
117
+ // the close through `onClose` (the host then flipped `open`), so firing it
118
+ // again here would double-call it.
119
+ const handleDismiss = useCallback(() => {
120
+ callQueuedCallbacks();
121
+ if (!isControlledRef.current) onCloseRef.current?.();
122
+ }, [callQueuedCallbacks]);
123
+
124
+ // In controlled mode, mirror the `open` prop onto the underlying sheet so both
125
+ // modes share one lifecycle.
126
+ useEffect(() => {
127
+ if (!isControlled) return;
128
+ if (controlledOpen) {
129
+ ref.current?.present();
130
+ } else {
131
+ ref.current?.dismiss();
132
+ }
133
+ }, [isControlled, controlledOpen]);
134
+
135
+ useImperativeHandle(control?.ref, () => ({ open, close }), [open, close]);
136
+
137
+ const context = useMemo(() => ({ close, isWithinDialog: true }), [close]);
138
+
139
+ // Backdrop press → dismiss, unless the host disabled it. Returning `false`
140
+ // from `onDismissAttempt` blocks the BottomSheet's own backdrop/pan dismissal,
141
+ // which is how `dismissOnBackdrop: false` maps onto the single sheet API.
142
+ const onDismissAttempt = useCallback(
143
+ () => dismissOnBackdrop,
144
+ [dismissOnBackdrop],
145
+ );
146
+
147
+ // `panelStyle` paints the sheet surface (e.g. a brand background). It composes
148
+ // AFTER the BottomSheet's internal background so a host palette wins; the
149
+ // shape (radius, max height) is supplied here and remains overridable.
150
+ const sheetStyle = useMemo<StyleProp<ViewStyle>>(() => {
151
+ const maxHeightPercent: `${number}%` = `${Math.round(maxHeightRatio * 100)}%`;
152
+ return [
153
+ {
154
+ backgroundColor: theme.colors.background,
155
+ borderTopLeftRadius: PANEL_RADIUS + 4,
156
+ borderTopRightRadius: PANEL_RADIUS + 4,
157
+ maxHeight: maxHeightPercent,
158
+ },
159
+ panelStyle,
160
+ ];
161
+ }, [theme.colors.background, maxHeightRatio, panelStyle]);
162
+
163
+ // Does this dialog render bloom's declarative chrome (title / description /
164
+ // actions), or is it pure custom `children`? Pure custom children own their
165
+ // own layout and scrolling, so we render them through a NON-scrollable
166
+ // BottomSheet body — otherwise the sheet's internal ScrollView would wrap a
167
+ // child that already contains its own scroller, producing a scroll-in-scroll
168
+ // (double scroll container). Declarative dialogs are short and keep the
169
+ // sheet's default scrollable body so they degrade gracefully on small
170
+ // viewports. This only changes what `DialogBottomSheet` passes to
171
+ // `BottomSheet`; the standalone `BottomSheet` API and its `scrollable` default
172
+ // are unchanged for direct consumers.
173
+ const hasDeclarativeChrome =
174
+ title !== undefined ||
175
+ description !== undefined ||
176
+ (actions !== undefined && actions.length > 0);
177
+
178
+ return (
179
+ <BottomSheet
180
+ ref={ref}
181
+ onDismiss={handleDismiss}
182
+ onDismissAttempt={onDismissAttempt}
183
+ enablePanDownToClose
184
+ showHandle={showHandle}
185
+ // Pure custom children own scrolling → opt OUT of BottomSheet's internal
186
+ // ScrollView so there is exactly ONE scroll container (no scroll-in-scroll).
187
+ scrollable={hasDeclarativeChrome}
188
+ // Stronger dim than a lone sheet so an underlying sheet's handle/content
189
+ // doesn't bleed through when a Dialog is stacked over one.
190
+ backdropOpacity={SHEET_BACKDROP_OPACITY + 0.3}
191
+ style={sheetStyle}
192
+ >
193
+ <Context.Provider value={context}>
194
+ <View
195
+ testID={testID}
196
+ accessibilityLabel={label}
197
+ aria-labelledby={title ? titleId : undefined}
198
+ aria-describedby={description ? descriptionId : undefined}
199
+ {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
200
+ {...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
201
+ style={[
202
+ { padding: contentPadding },
203
+ // `containerStyle` carries the host's CSS-var theme scope; it wraps
204
+ // the content subtree so descendants read the scoped palette.
205
+ containerStyle,
206
+ style,
207
+ panelStyle,
208
+ ]}
209
+ >
210
+ <DialogBody
211
+ titleId={titleId}
212
+ descriptionId={descriptionId}
213
+ title={title}
214
+ description={description}
215
+ actions={actions}
216
+ >
217
+ {children}
218
+ </DialogBody>
219
+ </View>
220
+ </Context.Provider>
221
+ </BottomSheet>
222
+ );
223
+ }
224
+
225
+ /**
226
+ * Props the shared bottom-sheet surface consumes — the `bottom`-relevant subset
227
+ * of `DialogProps`. `width`, `maxWidth`, and `inset` are placement-specific to
228
+ * the side/centered surfaces and intentionally excluded.
229
+ */
230
+ export type DialogBottomSheetProps = Pick<
231
+ DialogProps,
232
+ | 'control'
233
+ | 'open'
234
+ | 'onClose'
235
+ | 'testID'
236
+ | 'title'
237
+ | 'description'
238
+ | 'actions'
239
+ | 'maxHeightRatio'
240
+ | 'showHandle'
241
+ | 'dismissOnBackdrop'
242
+ | 'contentPadding'
243
+ | 'style'
244
+ | 'panelStyle'
245
+ | 'panelClassName'
246
+ | 'containerStyle'
247
+ | 'containerClassName'
248
+ | 'label'
249
+ | 'children'
250
+ >;