@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.
- package/lib/commonjs/dialog/Dialog.js +38 -150
- package/lib/commonjs/dialog/Dialog.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.web.js +55 -193
- package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
- package/lib/commonjs/dialog/DialogBottomSheet.js +205 -0
- package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -0
- package/lib/commonjs/dialog/DialogContent.js +155 -0
- package/lib/commonjs/dialog/DialogContent.js.map +1 -0
- package/lib/module/dialog/Dialog.js +41 -153
- package/lib/module/dialog/Dialog.js.map +1 -1
- package/lib/module/dialog/Dialog.web.js +57 -195
- package/lib/module/dialog/Dialog.web.js.map +1 -1
- package/lib/module/dialog/DialogBottomSheet.js +200 -0
- package/lib/module/dialog/DialogBottomSheet.js.map +1 -0
- package/lib/module/dialog/DialogContent.js +148 -0
- package/lib/module/dialog/DialogContent.js.map +1 -0
- package/lib/typescript/commonjs/dialog/Dialog.d.ts +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +11 -8
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +29 -0
- package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -0
- package/lib/typescript/commonjs/dialog/DialogContent.d.ts +32 -0
- package/lib/typescript/commonjs/dialog/DialogContent.d.ts.map +1 -0
- package/lib/typescript/module/dialog/Dialog.d.ts +1 -1
- package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
- package/lib/typescript/module/dialog/Dialog.web.d.ts +11 -8
- package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +29 -0
- package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -0
- package/lib/typescript/module/dialog/DialogContent.d.ts +32 -0
- package/lib/typescript/module/dialog/DialogContent.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/BottomSheetGesture.test.tsx +135 -0
- package/src/__tests__/DialogBottomSheet.test.tsx +130 -0
- package/src/dialog/Dialog.tsx +34 -166
- package/src/dialog/Dialog.web.tsx +46 -206
- package/src/dialog/DialogBottomSheet.tsx +235 -0
- 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,
|
|
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`,
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
79
|
-
*
|
|
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 =
|
|
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
|
|
426
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
}, [
|
|
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
|
|
822
|
-
*
|
|
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
|
+
>;
|