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