@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.
- package/lib/commonjs/dialog/Dialog.js +45 -157
- package/lib/commonjs/dialog/Dialog.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.web.js +64 -198
- package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
- package/lib/commonjs/dialog/DialogBottomSheet.js +216 -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/commonjs/dialog/placement.js +12 -1
- package/lib/commonjs/dialog/placement.js.map +1 -1
- package/lib/module/dialog/Dialog.js +48 -160
- package/lib/module/dialog/Dialog.js.map +1 -1
- package/lib/module/dialog/Dialog.web.js +66 -200
- package/lib/module/dialog/Dialog.web.js.map +1 -1
- package/lib/module/dialog/DialogBottomSheet.js +211 -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/module/dialog/placement.js +11 -0
- package/lib/module/dialog/placement.js.map +1 -1
- 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/commonjs/dialog/placement.d.ts +10 -0
- package/lib/typescript/commonjs/dialog/placement.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/types.d.ts +12 -0
- package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
- 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/lib/typescript/module/dialog/placement.d.ts +10 -0
- package/lib/typescript/module/dialog/placement.d.ts.map +1 -1
- package/lib/typescript/module/dialog/types.d.ts +12 -0
- package/lib/typescript/module/dialog/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/BottomSheetGesture.test.tsx +135 -0
- package/src/__tests__/DialogBottomSheet.test.tsx +259 -0
- package/src/dialog/Dialog.tsx +41 -171
- package/src/dialog/Dialog.web.tsx +56 -211
- package/src/dialog/DialogBottomSheet.tsx +250 -0
- package/src/dialog/DialogContent.tsx +157 -0
- package/src/dialog/placement.ts +11 -0
- 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,
|
|
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
|
-
|
|
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`,
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
79
|
-
*
|
|
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 =
|
|
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:
|
|
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
|
|
426
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
}, [
|
|
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
|
-
{
|
|
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
|
|
822
|
-
*
|
|
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
|
+
>;
|