@oxyhq/bloom 0.16.0 → 0.16.1

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 (39) hide show
  1. package/lib/commonjs/dialog/Dialog.js +38 -150
  2. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  3. package/lib/commonjs/dialog/Dialog.web.js +55 -193
  4. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  5. package/lib/commonjs/dialog/DialogBottomSheet.js +205 -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/module/dialog/Dialog.js +41 -153
  10. package/lib/module/dialog/Dialog.js.map +1 -1
  11. package/lib/module/dialog/Dialog.web.js +57 -195
  12. package/lib/module/dialog/Dialog.web.js.map +1 -1
  13. package/lib/module/dialog/DialogBottomSheet.js +200 -0
  14. package/lib/module/dialog/DialogBottomSheet.js.map +1 -0
  15. package/lib/module/dialog/DialogContent.js +148 -0
  16. package/lib/module/dialog/DialogContent.js.map +1 -0
  17. package/lib/typescript/commonjs/dialog/Dialog.d.ts +1 -1
  18. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +11 -8
  20. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +29 -0
  22. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -0
  23. package/lib/typescript/commonjs/dialog/DialogContent.d.ts +32 -0
  24. package/lib/typescript/commonjs/dialog/DialogContent.d.ts.map +1 -0
  25. package/lib/typescript/module/dialog/Dialog.d.ts +1 -1
  26. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  27. package/lib/typescript/module/dialog/Dialog.web.d.ts +11 -8
  28. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  29. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +29 -0
  30. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -0
  31. package/lib/typescript/module/dialog/DialogContent.d.ts +32 -0
  32. package/lib/typescript/module/dialog/DialogContent.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/__tests__/BottomSheetGesture.test.tsx +135 -0
  35. package/src/__tests__/DialogBottomSheet.test.tsx +130 -0
  36. package/src/dialog/Dialog.tsx +34 -166
  37. package/src/dialog/Dialog.web.tsx +46 -206
  38. package/src/dialog/DialogBottomSheet.tsx +235 -0
  39. package/src/dialog/DialogContent.tsx +157 -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,24 @@ 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,
33
30
  DEFAULT_SIDE_WIDTH,
34
31
  DIALOG_SHEET_BACKDROP_TESTID,
35
32
  EASE_OUT,
36
- HANDLE_HEIGHT,
37
- HANDLE_RADIUS,
38
- HANDLE_WIDTH,
39
33
  PANEL_RADIUS,
40
34
  SHEET_BACKDROP_OPACITY,
41
35
  SIDE_SHEET_MIN_GUTTER,
42
36
  useResolvedPlacement,
43
- type DialogPlacement,
44
37
  } from './placement';
45
38
  import type {
46
39
  DialogAction,
47
- DialogActionColor,
48
40
  DialogControlProps,
49
41
  DialogInset,
50
42
  DialogProps,
@@ -61,9 +53,10 @@ const ClosingContext = createContext(false);
61
53
  * Web variant of `<Dialog>`.
62
54
  *
63
55
  * 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.
56
+ * `document.body`, an anchored side-sheet for `left`/`right`, or for
57
+ * `bottom` bloom's cross-platform `BottomSheet` (the SAME component native
58
+ * uses), so the bottom placement drags-to-dismiss on web too. Same prop API as
59
+ * native, so call sites are platform agnostic.
67
60
  *
68
61
  * Open/close is driven by EITHER the imperative `control` (legacy) OR the
69
62
  * controlled `open` prop. When `open` is provided it wins.
@@ -75,10 +68,32 @@ const ClosingContext = createContext(false);
75
68
  *
76
69
  * Web motion never uses reanimated `exiting` (which throws `removeChild` on
77
70
  * 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.
71
+ * side modes use self-contained CSS transitions on a mounted-through-exit
72
+ * node. The bottom placement animates via `BottomSheet`'s shared-value/gesture
73
+ * animation (no `exiting` layout animation).
80
74
  */
81
- export function Dialog({
75
+ export function Dialog({ placement, ...rest }: DialogProps) {
76
+ const resolvedPlacement = useResolvedPlacement(placement);
77
+
78
+ // `bottom` routes to the shared cross-platform `BottomSheet` surface — the
79
+ // SAME component native uses — so drag-to-dismiss works on web too and there
80
+ // is no duplicated CSS slide-up sheet. `center` (default) and the side-sheets
81
+ // keep their DOM-portal implementation below. Each branch is its own
82
+ // component so the dispatcher only ever calls `useResolvedPlacement`, keeping
83
+ // the rules-of-hooks contract intact across a responsive placement change.
84
+ if (resolvedPlacement === 'bottom') {
85
+ return <DialogBottomSheet {...rest} />;
86
+ }
87
+ return <CenterOrSideDialog {...rest} placement={resolvedPlacement} />;
88
+ }
89
+
90
+ /**
91
+ * Web `center` (default) and `left`/`right` placements. The centered card is a
92
+ * DOM-portal modal; the side placements are CSS-transition sheets. The `bottom`
93
+ * placement is handled separately by `DialogBottomSheet` (bloom's cross-platform
94
+ * `BottomSheet`).
95
+ */
96
+ function CenterOrSideDialog({
82
97
  control,
83
98
  open: controlledOpen,
84
99
  onClose,
@@ -89,9 +104,7 @@ export function Dialog({
89
104
  placement,
90
105
  width = DEFAULT_SIDE_WIDTH,
91
106
  maxWidth = DEFAULT_CENTER_MAX_WIDTH,
92
- maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
93
107
  inset,
94
- showHandle = true,
95
108
  dismissOnBackdrop = true,
96
109
  style,
97
110
  panelStyle,
@@ -100,11 +113,11 @@ export function Dialog({
100
113
  containerClassName,
101
114
  label,
102
115
  children,
103
- }: DialogProps) {
116
+ }: Omit<DialogProps, 'placement'> & { placement: 'center' | 'left' | 'right' }) {
104
117
  // Controlled mode is opt-in: when `open` is a boolean the host owns the
105
118
  // visible state; otherwise the legacy imperative `control` path drives it.
106
119
  const isControlled = controlledOpen !== undefined;
107
- const resolvedPlacement = useResolvedPlacement(placement);
120
+ const resolvedPlacement = placement;
108
121
 
109
122
  const [isOpen, setIsOpen] = useState(false);
110
123
  const [isClosing, setIsClosing] = useState(false);
@@ -272,9 +285,7 @@ export function Dialog({
272
285
  placement={resolvedPlacement}
273
286
  shown={!isClosing}
274
287
  width={width}
275
- maxHeightRatio={maxHeightRatio}
276
288
  inset={inset}
277
- showHandle={showHandle}
278
289
  dismissOnBackdrop={dismissOnBackdrop}
279
290
  onDismiss={close}
280
291
  panelStyle={panelStyle}
@@ -291,64 +302,6 @@ export function Dialog({
291
302
  );
292
303
  }
293
304
 
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
305
  function DialogPanel({
353
306
  testID,
354
307
  label,
@@ -422,10 +375,13 @@ function DialogPanel({
422
375
  export { DIALOG_SHEET_BACKDROP_TESTID };
423
376
 
424
377
  /**
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
378
+ * Side-sheet surface for the `left`/`right` placements. Pure CSS transitions:
379
+ * the node stays mounted through the exit while a `shown` flag drives the
428
380
  * transform/opacity, so both directions animate without reanimated `exiting`.
381
+ *
382
+ * The `bottom` placement is NOT handled here — it routes through
383
+ * `DialogBottomSheet` (bloom's cross-platform `BottomSheet`) so it shares one
384
+ * implementation with native and supports drag-to-dismiss on web.
429
385
  */
430
386
  function SheetSurface({
431
387
  testID,
@@ -436,9 +392,7 @@ function SheetSurface({
436
392
  placement,
437
393
  shown,
438
394
  width,
439
- maxHeightRatio,
440
395
  inset,
441
- showHandle,
442
396
  dismissOnBackdrop,
443
397
  onDismiss,
444
398
  panelStyle,
@@ -453,12 +407,10 @@ function SheetSurface({
453
407
  title?: string;
454
408
  description?: string;
455
409
  actions?: DialogAction[];
456
- placement: Exclude<DialogPlacement, 'center'>;
410
+ placement: 'left' | 'right';
457
411
  shown: boolean;
458
412
  width: number;
459
- maxHeightRatio: number;
460
413
  inset?: DialogInset;
461
- showHandle: boolean;
462
414
  dismissOnBackdrop: boolean;
463
415
  onDismiss: () => void;
464
416
  panelStyle?: StyleProp<ViewStyle>;
@@ -471,7 +423,7 @@ function SheetSurface({
471
423
  const theme = useTheme();
472
424
  const titleId = useId();
473
425
  const descriptionId = useId();
474
- const { width: viewportWidth, height: viewportHeight } = useWindowDimensions();
426
+ const { width: viewportWidth } = useWindowDimensions();
475
427
 
476
428
  // Defer the entry transition by a frame so the start state paints before the
477
429
  // browser animates to `shown`. `entered` is false on the first committed
@@ -489,7 +441,6 @@ function SheetSurface({
489
441
  }, []);
490
442
 
491
443
  const visible = shown && entered;
492
- const isBottom = placement === 'bottom';
493
444
 
494
445
  const panelTransition = useMemo<ViewStyle>(
495
446
  () =>
@@ -512,19 +463,6 @@ function SheetSurface({
512
463
  );
513
464
 
514
465
  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
466
  const insetTop = inset?.top ?? 0;
529
467
  const insetBottom = inset?.bottom ?? 0;
530
468
  const insetLeft = inset?.left ?? 0;
@@ -544,7 +482,7 @@ function SheetSurface({
544
482
  transform: [{ translateX: visible ? 0 : hiddenSign }],
545
483
  opacity: visible ? 1 : 0,
546
484
  } as ViewStyle;
547
- }, [isBottom, visible, placement, width, inset, viewportWidth, viewportHeight, maxHeightRatio]);
485
+ }, [visible, placement, width, inset, viewportWidth]);
548
486
 
549
487
  const handleBackdropPress = useCallback(() => {
550
488
  if (dismissOnBackdrop) onDismiss();
@@ -590,11 +528,6 @@ function SheetSurface({
590
528
  ]}
591
529
  pointerEvents="auto"
592
530
  >
593
- {isBottom && showHandle ? (
594
- <View style={sheetStyles.handleRow} pointerEvents="none">
595
- <View style={[sheetStyles.handle, { backgroundColor: theme.colors.border }]} />
596
- </View>
597
- ) : null}
598
531
  <View style={sheetStyles.body}>
599
532
  <DialogBody
600
533
  titleId={titleId}
@@ -632,88 +565,6 @@ function cancelFrame(token: FrameToken): void {
632
565
  clearTimeout(token.timer);
633
566
  }
634
567
 
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
568
  function DialogBackdrop({ isClosing }: { isClosing: boolean }) {
718
569
  const style: ViewStyle[] = [
719
570
  {
@@ -790,18 +641,6 @@ const sheetStyles = {
790
641
  shadowOffset: { width: 0, height: 8 },
791
642
  zIndex: dialogZIndex.surface,
792
643
  } 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
644
  body: {
806
645
  padding: 20,
807
646
  } as ViewStyle,
@@ -818,8 +657,9 @@ const sheetStyles = {
818
657
  * @keyframes bloomDialogZoomFadeOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
819
658
  * ```
820
659
  *
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.
660
+ * The `left`/`right` placements do NOT depend on this — they animate via
661
+ * self-contained inline CSS transitions and need no keyframe injection. The
662
+ * `bottom` placement uses bloom's `BottomSheet` (reanimated) and needs none.
823
663
  */
824
664
  export const BLOOM_DIALOG_CSS = `
825
665
  @keyframes bloomDialogFadeIn { from { opacity: 0; } to { opacity: 1; } }
@@ -0,0 +1,235 @@
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_MAX_HEIGHT_RATIO,
17
+ PANEL_RADIUS,
18
+ SHEET_BACKDROP_OPACITY,
19
+ } from './placement';
20
+ import type { DialogControlProps, DialogProps } from './types';
21
+
22
+ /**
23
+ * Padding of the bottom-sheet content container (px). Matches the side-sheet
24
+ * and centered-panel body padding so every placement reads identically.
25
+ */
26
+ const SHEET_CONTENT_PADDING = 20;
27
+
28
+ /**
29
+ * Shared `BottomSheet`-backed surface for the `bottom` placement, used by BOTH
30
+ * `Dialog.tsx` (native) and `Dialog.web.tsx` (web). `BottomSheet` is itself a
31
+ * single cross-platform implementation (react-native-gesture-handler +
32
+ * reanimated, drag-to-dismiss on web and native alike), so routing the bottom
33
+ * placement through here gives ONE code path with drag working everywhere — no
34
+ * duplicated CSS slide-up sheet.
35
+ *
36
+ * This component owns the entire open↔present/dismiss bridge and the Dialog →
37
+ * BottomSheet prop mapping in one place:
38
+ *
39
+ * - `open` (controlled) / `control` (imperative) → `present()` / `dismiss()`
40
+ * - `onClose` → fired once the sheet settles
41
+ * - `maxHeightRatio` → sheet `maxHeight`
42
+ * - `showHandle` / `dismissOnBackdrop` / `label` → forwarded to the surface
43
+ * - `panelStyle` → paints the sheet surface
44
+ * - `containerStyle` → wraps the content subtree
45
+ * so a host's CSS-var theme scope (`vars()`) still applies to descendants
46
+ * - `title` / `description` / `actions` / `children` → declarative body
47
+ */
48
+ export function DialogBottomSheet({
49
+ control,
50
+ open: controlledOpen,
51
+ onClose,
52
+ testID,
53
+ title,
54
+ description,
55
+ actions,
56
+ maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
57
+ showHandle = true,
58
+ dismissOnBackdrop = true,
59
+ style,
60
+ panelStyle,
61
+ panelClassName,
62
+ containerStyle,
63
+ containerClassName,
64
+ label,
65
+ children,
66
+ }: DialogBottomSheetProps) {
67
+ const isControlled = controlledOpen !== undefined;
68
+ const theme = useTheme();
69
+ const ref = useRef<BottomSheetRef>(null);
70
+ const closeCallbacks = useRef<(() => void)[]>([]);
71
+ const titleId = useId();
72
+ const descriptionId = useId();
73
+
74
+ // Read the latest controlled flag / `onClose` inside stable callbacks without
75
+ // re-binding them (the context + imperative handle depend on `close` staying
76
+ // referentially stable).
77
+ const isControlledRef = useRef(isControlled);
78
+ isControlledRef.current = isControlled;
79
+ const onCloseRef = useRef(onClose);
80
+ onCloseRef.current = onClose;
81
+
82
+ // Drain queued close callbacks atomically — capturing the list and resetting
83
+ // it before invocation ensures a callback that synchronously re-opens the
84
+ // dialog (and queues fresh callbacks) does not see the old ones replayed
85
+ // against the new session.
86
+ const callQueuedCallbacks = useCallback(() => {
87
+ const queued = closeCallbacks.current;
88
+ closeCallbacks.current = [];
89
+ for (const cb of queued) {
90
+ try {
91
+ cb();
92
+ } catch (e) {
93
+ if (typeof console !== 'undefined' && console.error) {
94
+ console.error('Dialog close callback error:', e);
95
+ }
96
+ }
97
+ }
98
+ }, []);
99
+
100
+ const open = useCallback(() => {
101
+ ref.current?.present();
102
+ }, []);
103
+
104
+ // A dismissal request (backdrop, pan-to-close, action button). In controlled
105
+ // mode it asks the host to close via `onClose` (the host flips `open`); in
106
+ // imperative mode it dismisses the surface directly. The optional callback
107
+ // runs once the dialog has finished closing.
108
+ const close = useCallback<DialogControlProps['close']>((cb) => {
109
+ if (typeof cb === 'function') {
110
+ closeCallbacks.current.push(cb);
111
+ }
112
+ if (isControlledRef.current) {
113
+ onCloseRef.current?.();
114
+ return;
115
+ }
116
+ ref.current?.dismiss();
117
+ }, []);
118
+
119
+ // Fired when the sheet has finished closing. Drains queued callbacks, then
120
+ // fires `onClose` ONLY in imperative mode — controlled mode already requested
121
+ // the close through `onClose` (the host then flipped `open`), so firing it
122
+ // again here would double-call it.
123
+ const handleDismiss = useCallback(() => {
124
+ callQueuedCallbacks();
125
+ if (!isControlledRef.current) onCloseRef.current?.();
126
+ }, [callQueuedCallbacks]);
127
+
128
+ // In controlled mode, mirror the `open` prop onto the underlying sheet so both
129
+ // modes share one lifecycle.
130
+ useEffect(() => {
131
+ if (!isControlled) return;
132
+ if (controlledOpen) {
133
+ ref.current?.present();
134
+ } else {
135
+ ref.current?.dismiss();
136
+ }
137
+ }, [isControlled, controlledOpen]);
138
+
139
+ useImperativeHandle(control?.ref, () => ({ open, close }), [open, close]);
140
+
141
+ const context = useMemo(() => ({ close, isWithinDialog: true }), [close]);
142
+
143
+ // Backdrop press → dismiss, unless the host disabled it. Returning `false`
144
+ // from `onDismissAttempt` blocks the BottomSheet's own backdrop/pan dismissal,
145
+ // which is how `dismissOnBackdrop: false` maps onto the single sheet API.
146
+ const onDismissAttempt = useCallback(
147
+ () => dismissOnBackdrop,
148
+ [dismissOnBackdrop],
149
+ );
150
+
151
+ // `panelStyle` paints the sheet surface (e.g. a brand background). It composes
152
+ // AFTER the BottomSheet's internal background so a host palette wins; the
153
+ // shape (radius, max height) is supplied here and remains overridable.
154
+ const sheetStyle = useMemo<StyleProp<ViewStyle>>(() => {
155
+ const maxHeightPercent: `${number}%` = `${Math.round(maxHeightRatio * 100)}%`;
156
+ return [
157
+ {
158
+ backgroundColor: theme.colors.background,
159
+ borderTopLeftRadius: PANEL_RADIUS + 4,
160
+ borderTopRightRadius: PANEL_RADIUS + 4,
161
+ maxHeight: maxHeightPercent,
162
+ },
163
+ panelStyle,
164
+ ];
165
+ }, [theme.colors.background, maxHeightRatio, panelStyle]);
166
+
167
+ return (
168
+ <BottomSheet
169
+ ref={ref}
170
+ onDismiss={handleDismiss}
171
+ onDismissAttempt={onDismissAttempt}
172
+ enablePanDownToClose
173
+ showHandle={showHandle}
174
+ // Stronger dim than a lone sheet so an underlying sheet's handle/content
175
+ // doesn't bleed through when a Dialog is stacked over one.
176
+ backdropOpacity={SHEET_BACKDROP_OPACITY + 0.3}
177
+ style={sheetStyle}
178
+ >
179
+ <Context.Provider value={context}>
180
+ <View
181
+ testID={testID}
182
+ accessibilityLabel={label}
183
+ aria-labelledby={title ? titleId : undefined}
184
+ aria-describedby={description ? descriptionId : undefined}
185
+ {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
186
+ {...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
187
+ style={[
188
+ { padding: SHEET_CONTENT_PADDING },
189
+ // `containerStyle` carries the host's CSS-var theme scope; it wraps
190
+ // the content subtree so descendants read the scoped palette.
191
+ containerStyle,
192
+ style,
193
+ panelStyle,
194
+ ]}
195
+ >
196
+ <DialogBody
197
+ titleId={titleId}
198
+ descriptionId={descriptionId}
199
+ title={title}
200
+ description={description}
201
+ actions={actions}
202
+ >
203
+ {children}
204
+ </DialogBody>
205
+ </View>
206
+ </Context.Provider>
207
+ </BottomSheet>
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Props the shared bottom-sheet surface consumes — the `bottom`-relevant subset
213
+ * of `DialogProps`. `width`, `maxWidth`, and `inset` are placement-specific to
214
+ * the side/centered surfaces and intentionally excluded.
215
+ */
216
+ export type DialogBottomSheetProps = Pick<
217
+ DialogProps,
218
+ | 'control'
219
+ | 'open'
220
+ | 'onClose'
221
+ | 'testID'
222
+ | 'title'
223
+ | 'description'
224
+ | 'actions'
225
+ | 'maxHeightRatio'
226
+ | 'showHandle'
227
+ | 'dismissOnBackdrop'
228
+ | 'style'
229
+ | 'panelStyle'
230
+ | 'panelClassName'
231
+ | 'containerStyle'
232
+ | 'containerClassName'
233
+ | 'label'
234
+ | 'children'
235
+ >;