@oxyhq/bloom 0.30.10 → 0.32.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/alert-dialog/AlertDialog.js +91 -67
- package/lib/commonjs/alert-dialog/AlertDialog.js.map +1 -1
- package/lib/module/alert-dialog/AlertDialog.js +93 -69
- package/lib/module/alert-dialog/AlertDialog.js.map +1 -1
- package/lib/typescript/commonjs/alert-dialog/AlertDialog.d.ts +21 -2
- package/lib/typescript/commonjs/alert-dialog/AlertDialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/alert-dialog/types.d.ts +8 -2
- package/lib/typescript/commonjs/alert-dialog/types.d.ts.map +1 -1
- package/lib/typescript/module/alert-dialog/AlertDialog.d.ts +21 -2
- package/lib/typescript/module/alert-dialog/AlertDialog.d.ts.map +1 -1
- package/lib/typescript/module/alert-dialog/types.d.ts +8 -2
- package/lib/typescript/module/alert-dialog/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/AlertDialog.test.tsx +177 -15
- package/src/__tests__/confirm-store.test.ts +16 -0
- package/src/alert-dialog/AlertDialog.tsx +92 -77
- package/src/alert-dialog/types.ts +8 -2
|
@@ -5,16 +5,9 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.createAlertDialog = createAlertDialog;
|
|
7
7
|
var _react = _interopRequireWildcard(require("react"));
|
|
8
|
-
var
|
|
9
|
-
var _button = require("../button");
|
|
10
|
-
var _useTheme = require("../theme/use-theme.js");
|
|
11
|
-
var _index = require("../typography/index.js");
|
|
12
|
-
var _tokens = require("../styles/tokens.js");
|
|
8
|
+
var _context = require("../dialog/context.js");
|
|
13
9
|
var _jsxRuntime = require("react/jsx-runtime");
|
|
14
10
|
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
15
|
-
/** Centered alert-card max width (px). Matches the legacy AlertDialog card. */
|
|
16
|
-
const ALERT_DIALOG_MAX_WIDTH = 440;
|
|
17
|
-
|
|
18
11
|
/**
|
|
19
12
|
* Build the `AlertDialog` component bound to a platform `Dialog`.
|
|
20
13
|
*
|
|
@@ -24,10 +17,29 @@ const ALERT_DIALOG_MAX_WIDTH = 440;
|
|
|
24
17
|
* (`index.ts` / `index.web.ts`) inject the correct `Dialog` here. The body
|
|
25
18
|
* below is single-source, no duplication.
|
|
26
19
|
*
|
|
27
|
-
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that
|
|
28
|
-
*
|
|
20
|
+
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that maps
|
|
21
|
+
* the confirm/cancel props onto the Dialog's declarative `actions` row — the
|
|
22
|
+
* SAME battle-tested `ActionRow`/`ActionButton` primitive every other bloom
|
|
23
|
+
* confirm surface uses (e.g. Mention's `ConfirmPrompt`), so the button
|
|
24
|
+
* component, layout, and palette stay identical across apps. Reach for the
|
|
29
25
|
* imperative `confirm()` helper + `<AlertDialogHost />` to trigger a confirm
|
|
30
26
|
* from an event handler without owning visible state.
|
|
27
|
+
*
|
|
28
|
+
* Control mode — IMPERATIVE, not controlled. `AlertDialog` keeps its public
|
|
29
|
+
* *controlled* API (`visible` boolean + `onClose`) but internally bridges it
|
|
30
|
+
* onto the Dialog's imperative `useDialogControl()` handle, so `confirm()`
|
|
31
|
+
* drives `<Dialog>` through the EXACT uncontrolled/imperative internals
|
|
32
|
+
* Mention's `ConfirmPrompt` uses. That is the code path that actually plays the
|
|
33
|
+
* exit animation on dismiss: imperative `close()` sets `isClosing`, runs the
|
|
34
|
+
* exit animation, and fires `onClose` only once it settles. The controlled path
|
|
35
|
+
* instead fires `onClose` synchronously — and because `<AlertDialogHost />`
|
|
36
|
+
* resolves + drops the queue entry inside `onClose`, the surface would then
|
|
37
|
+
* unmount instantly, skipping the exit animation entirely. Bridging to
|
|
38
|
+
* imperative mode eliminates that fork so `confirm()` and Mention's
|
|
39
|
+
* `ConfirmPrompt` are pixel/timing identical.
|
|
40
|
+
*
|
|
41
|
+
* The centered card uses the Dialog default `maxWidth` (480px) for full visual
|
|
42
|
+
* parity with the shared confirm surface.
|
|
31
43
|
*/
|
|
32
44
|
function createAlertDialog(Dialog) {
|
|
33
45
|
const AlertDialogComponent = function AlertDialog({
|
|
@@ -41,77 +53,89 @@ function createAlertDialog(Dialog) {
|
|
|
41
53
|
onCancel,
|
|
42
54
|
destructive = false,
|
|
43
55
|
hideCancel = false,
|
|
44
|
-
dismissible =
|
|
56
|
+
dismissible = true,
|
|
45
57
|
cardStyle,
|
|
46
58
|
testID
|
|
47
59
|
}) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
const control = (0, _context.useDialogControl)();
|
|
61
|
+
|
|
62
|
+
// Bridge the public *controlled* `visible` prop onto the Dialog's
|
|
63
|
+
// imperative open/close. Opening on mount straight from an effect (no
|
|
64
|
+
// `setTimeout`) mirrors bloom's own `AutoMountedDialog` (which backs
|
|
65
|
+
// `alert()`) — the canonical fresh-mount imperative-open pattern:
|
|
66
|
+
// `<AlertDialogHost />` mounts a FRESH `AlertDialog` per confirm
|
|
67
|
+
// (`key={id}`) with the final content already committed, so there is no
|
|
68
|
+
// stale-content window to defer past and the entry animation curve stays
|
|
69
|
+
// identical to Mention's. `control` is referentially stable (memoised on
|
|
70
|
+
// its id), so this effect only re-runs when `visible` actually flips. When
|
|
71
|
+
// a direct consumer flips `visible` to `false`, we imperatively `close()`
|
|
72
|
+
// so the exit animation still plays.
|
|
73
|
+
const closingFromPropRef = (0, _react.useRef)(false);
|
|
74
|
+
(0, _react.useEffect)(() => {
|
|
75
|
+
if (visible) {
|
|
76
|
+
control.open();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
closingFromPropRef.current = true;
|
|
80
|
+
control.close();
|
|
81
|
+
}, [visible, control]);
|
|
82
|
+
|
|
83
|
+
// The Dialog fires `onClose` after the exit animation settles (imperative
|
|
84
|
+
// mode). Forward ONLY user-initiated dismissals (backdrop / Escape / cancel)
|
|
85
|
+
// to the consumer — a consumer-initiated close (they flipped `visible` to
|
|
86
|
+
// `false`) must not re-enter `onClose`, preserving the previous controlled
|
|
87
|
+
// semantics where a programmatic close does not fire `onClose`.
|
|
88
|
+
const handleClose = (0, _react.useCallback)(() => {
|
|
89
|
+
if (closingFromPropRef.current) {
|
|
90
|
+
closingFromPropRef.current = false;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
55
93
|
onClose();
|
|
56
|
-
}, [
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
94
|
+
}, [onClose]);
|
|
95
|
+
|
|
96
|
+
// Confirm/cancel map onto the Dialog's declarative action row. Both keep the
|
|
97
|
+
// default `shouldCloseOnPress` (true): the button runs the exit animation
|
|
98
|
+
// FIRST, then invokes `onPress` as a close-completion callback — so
|
|
99
|
+
// `onConfirm` resolves the `confirm()` promise as the surface animates out,
|
|
100
|
+
// exactly like Mention. `onConfirm` (resolve `true`, drains the entry) runs
|
|
101
|
+
// just BEFORE the Dialog's own post-close `onClose` (→ `handleClose` →
|
|
102
|
+
// resolve `false`, now a no-op for the drained entry), which is what makes
|
|
103
|
+
// the confirm button win the double-resolution guard.
|
|
104
|
+
const actions = [{
|
|
105
|
+
label: confirmLabel,
|
|
106
|
+
onPress: onConfirm,
|
|
107
|
+
color: destructive ? 'destructive' : 'default'
|
|
108
|
+
}];
|
|
109
|
+
if (!hideCancel) {
|
|
110
|
+
// The `cancel` color auto-dismisses via the Dialog's `close()` (exit
|
|
111
|
+
// animation), then runs `onCancel` as a close-completion callback.
|
|
112
|
+
actions.push({
|
|
113
|
+
label: cancelLabel,
|
|
114
|
+
onPress: onCancel,
|
|
115
|
+
color: 'cancel'
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(Dialog, {
|
|
119
|
+
control: control,
|
|
120
|
+
onClose: handleClose,
|
|
60
121
|
placement: "center"
|
|
61
|
-
// Alert dialogs
|
|
62
|
-
//
|
|
122
|
+
// Alert dialogs dismiss on backdrop / Escape by default (matching the
|
|
123
|
+
// shared Dialog default and Mention's `ConfirmPrompt`). A backdrop /
|
|
124
|
+
// Escape dismissal can only ever resolve the confirm as cancelled
|
|
125
|
+
// (`false`), never the destructive action — callers that need a truly
|
|
126
|
+
// blocking confirm opt out with `dismissible={false}`.
|
|
63
127
|
,
|
|
64
128
|
dismissOnBackdrop: dismissible,
|
|
65
|
-
maxWidth: ALERT_DIALOG_MAX_WIDTH,
|
|
66
129
|
title: title,
|
|
130
|
+
description: description,
|
|
131
|
+
actions: actions,
|
|
67
132
|
label: title,
|
|
68
133
|
style: cardStyle,
|
|
69
|
-
testID: testID
|
|
70
|
-
children: [description ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_index.Text, {
|
|
71
|
-
style: {
|
|
72
|
-
fontSize: _tokens.fontSize.md,
|
|
73
|
-
lineHeight: _tokens.fontSize.md * 1.4,
|
|
74
|
-
color: theme.colors.textSecondary,
|
|
75
|
-
paddingBottom: _tokens.space.lg
|
|
76
|
-
},
|
|
77
|
-
children: description
|
|
78
|
-
}) : null, /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
|
|
79
|
-
style: styles.actions,
|
|
80
|
-
children: [!hideCancel ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_button.Button, {
|
|
81
|
-
variant: "secondary",
|
|
82
|
-
size: "medium",
|
|
83
|
-
onPress: handleCancel,
|
|
84
|
-
style: styles.action,
|
|
85
|
-
accessibilityLabel: cancelLabel,
|
|
86
|
-
children: cancelLabel
|
|
87
|
-
}) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_button.Button, {
|
|
88
|
-
variant: "primary",
|
|
89
|
-
size: "medium",
|
|
90
|
-
onPress: handleConfirm,
|
|
91
|
-
style: _reactNative.StyleSheet.flatten([styles.action, destructive && {
|
|
92
|
-
backgroundColor: theme.colors.negative
|
|
93
|
-
}]),
|
|
94
|
-
textStyle: destructive ? {
|
|
95
|
-
color: theme.colors.negativeForeground
|
|
96
|
-
} : undefined,
|
|
97
|
-
accessibilityLabel: confirmLabel,
|
|
98
|
-
children: confirmLabel
|
|
99
|
-
})]
|
|
100
|
-
})]
|
|
134
|
+
testID: testID
|
|
101
135
|
});
|
|
102
136
|
};
|
|
103
137
|
const AlertDialog = /*#__PURE__*/(0, _react.memo)(AlertDialogComponent);
|
|
104
138
|
AlertDialog.displayName = 'AlertDialog';
|
|
105
139
|
return AlertDialog;
|
|
106
140
|
}
|
|
107
|
-
const styles = _reactNative.StyleSheet.create({
|
|
108
|
-
actions: {
|
|
109
|
-
flexDirection: 'row',
|
|
110
|
-
justifyContent: 'flex-end',
|
|
111
|
-
gap: _tokens.space.sm
|
|
112
|
-
},
|
|
113
|
-
action: {
|
|
114
|
-
minWidth: 96
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
141
|
//# sourceMappingURL=AlertDialog.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["_react","_interopRequireWildcard","require","
|
|
1
|
+
{"version":3,"names":["_react","_interopRequireWildcard","require","_context","_jsxRuntime","e","t","WeakMap","r","n","__esModule","o","i","f","__proto__","default","has","get","set","hasOwnProperty","call","Object","defineProperty","getOwnPropertyDescriptor","createAlertDialog","Dialog","AlertDialogComponent","AlertDialog","visible","onClose","title","description","confirmLabel","cancelLabel","onConfirm","onCancel","destructive","hideCancel","dismissible","cardStyle","testID","control","useDialogControl","closingFromPropRef","useRef","useEffect","open","current","close","handleClose","useCallback","actions","label","onPress","color","push","jsx","placement","dismissOnBackdrop","style","memo","displayName"],"sourceRoot":"../../../src","sources":["alert-dialog/AlertDialog.tsx"],"mappings":"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","ignoreList":[]}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import React, { memo, useCallback } from 'react';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { useTheme } from "../theme/use-theme.js";
|
|
7
|
-
import { Text } from "../typography/index.js";
|
|
8
|
-
import { fontSize, space } from "../styles/tokens.js";
|
|
9
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
10
|
-
/** Centered alert-card max width (px). Matches the legacy AlertDialog card. */
|
|
11
|
-
const ALERT_DIALOG_MAX_WIDTH = 440;
|
|
12
|
-
|
|
3
|
+
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
import { useDialogControl } from "../dialog/context.js";
|
|
5
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
13
6
|
/**
|
|
14
7
|
* Build the `AlertDialog` component bound to a platform `Dialog`.
|
|
15
8
|
*
|
|
@@ -19,10 +12,29 @@ const ALERT_DIALOG_MAX_WIDTH = 440;
|
|
|
19
12
|
* (`index.ts` / `index.web.ts`) inject the correct `Dialog` here. The body
|
|
20
13
|
* below is single-source, no duplication.
|
|
21
14
|
*
|
|
22
|
-
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that
|
|
23
|
-
*
|
|
15
|
+
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that maps
|
|
16
|
+
* the confirm/cancel props onto the Dialog's declarative `actions` row — the
|
|
17
|
+
* SAME battle-tested `ActionRow`/`ActionButton` primitive every other bloom
|
|
18
|
+
* confirm surface uses (e.g. Mention's `ConfirmPrompt`), so the button
|
|
19
|
+
* component, layout, and palette stay identical across apps. Reach for the
|
|
24
20
|
* imperative `confirm()` helper + `<AlertDialogHost />` to trigger a confirm
|
|
25
21
|
* from an event handler without owning visible state.
|
|
22
|
+
*
|
|
23
|
+
* Control mode — IMPERATIVE, not controlled. `AlertDialog` keeps its public
|
|
24
|
+
* *controlled* API (`visible` boolean + `onClose`) but internally bridges it
|
|
25
|
+
* onto the Dialog's imperative `useDialogControl()` handle, so `confirm()`
|
|
26
|
+
* drives `<Dialog>` through the EXACT uncontrolled/imperative internals
|
|
27
|
+
* Mention's `ConfirmPrompt` uses. That is the code path that actually plays the
|
|
28
|
+
* exit animation on dismiss: imperative `close()` sets `isClosing`, runs the
|
|
29
|
+
* exit animation, and fires `onClose` only once it settles. The controlled path
|
|
30
|
+
* instead fires `onClose` synchronously — and because `<AlertDialogHost />`
|
|
31
|
+
* resolves + drops the queue entry inside `onClose`, the surface would then
|
|
32
|
+
* unmount instantly, skipping the exit animation entirely. Bridging to
|
|
33
|
+
* imperative mode eliminates that fork so `confirm()` and Mention's
|
|
34
|
+
* `ConfirmPrompt` are pixel/timing identical.
|
|
35
|
+
*
|
|
36
|
+
* The centered card uses the Dialog default `maxWidth` (480px) for full visual
|
|
37
|
+
* parity with the shared confirm surface.
|
|
26
38
|
*/
|
|
27
39
|
export function createAlertDialog(Dialog) {
|
|
28
40
|
const AlertDialogComponent = function AlertDialog({
|
|
@@ -36,77 +48,89 @@ export function createAlertDialog(Dialog) {
|
|
|
36
48
|
onCancel,
|
|
37
49
|
destructive = false,
|
|
38
50
|
hideCancel = false,
|
|
39
|
-
dismissible =
|
|
51
|
+
dismissible = true,
|
|
40
52
|
cardStyle,
|
|
41
53
|
testID
|
|
42
54
|
}) {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
const control = useDialogControl();
|
|
56
|
+
|
|
57
|
+
// Bridge the public *controlled* `visible` prop onto the Dialog's
|
|
58
|
+
// imperative open/close. Opening on mount straight from an effect (no
|
|
59
|
+
// `setTimeout`) mirrors bloom's own `AutoMountedDialog` (which backs
|
|
60
|
+
// `alert()`) — the canonical fresh-mount imperative-open pattern:
|
|
61
|
+
// `<AlertDialogHost />` mounts a FRESH `AlertDialog` per confirm
|
|
62
|
+
// (`key={id}`) with the final content already committed, so there is no
|
|
63
|
+
// stale-content window to defer past and the entry animation curve stays
|
|
64
|
+
// identical to Mention's. `control` is referentially stable (memoised on
|
|
65
|
+
// its id), so this effect only re-runs when `visible` actually flips. When
|
|
66
|
+
// a direct consumer flips `visible` to `false`, we imperatively `close()`
|
|
67
|
+
// so the exit animation still plays.
|
|
68
|
+
const closingFromPropRef = useRef(false);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (visible) {
|
|
71
|
+
control.open();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
closingFromPropRef.current = true;
|
|
75
|
+
control.close();
|
|
76
|
+
}, [visible, control]);
|
|
77
|
+
|
|
78
|
+
// The Dialog fires `onClose` after the exit animation settles (imperative
|
|
79
|
+
// mode). Forward ONLY user-initiated dismissals (backdrop / Escape / cancel)
|
|
80
|
+
// to the consumer — a consumer-initiated close (they flipped `visible` to
|
|
81
|
+
// `false`) must not re-enter `onClose`, preserving the previous controlled
|
|
82
|
+
// semantics where a programmatic close does not fire `onClose`.
|
|
83
|
+
const handleClose = useCallback(() => {
|
|
84
|
+
if (closingFromPropRef.current) {
|
|
85
|
+
closingFromPropRef.current = false;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
50
88
|
onClose();
|
|
51
|
-
}, [
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
89
|
+
}, [onClose]);
|
|
90
|
+
|
|
91
|
+
// Confirm/cancel map onto the Dialog's declarative action row. Both keep the
|
|
92
|
+
// default `shouldCloseOnPress` (true): the button runs the exit animation
|
|
93
|
+
// FIRST, then invokes `onPress` as a close-completion callback — so
|
|
94
|
+
// `onConfirm` resolves the `confirm()` promise as the surface animates out,
|
|
95
|
+
// exactly like Mention. `onConfirm` (resolve `true`, drains the entry) runs
|
|
96
|
+
// just BEFORE the Dialog's own post-close `onClose` (→ `handleClose` →
|
|
97
|
+
// resolve `false`, now a no-op for the drained entry), which is what makes
|
|
98
|
+
// the confirm button win the double-resolution guard.
|
|
99
|
+
const actions = [{
|
|
100
|
+
label: confirmLabel,
|
|
101
|
+
onPress: onConfirm,
|
|
102
|
+
color: destructive ? 'destructive' : 'default'
|
|
103
|
+
}];
|
|
104
|
+
if (!hideCancel) {
|
|
105
|
+
// The `cancel` color auto-dismisses via the Dialog's `close()` (exit
|
|
106
|
+
// animation), then runs `onCancel` as a close-completion callback.
|
|
107
|
+
actions.push({
|
|
108
|
+
label: cancelLabel,
|
|
109
|
+
onPress: onCancel,
|
|
110
|
+
color: 'cancel'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return /*#__PURE__*/_jsx(Dialog, {
|
|
114
|
+
control: control,
|
|
115
|
+
onClose: handleClose,
|
|
55
116
|
placement: "center"
|
|
56
|
-
// Alert dialogs
|
|
57
|
-
//
|
|
117
|
+
// Alert dialogs dismiss on backdrop / Escape by default (matching the
|
|
118
|
+
// shared Dialog default and Mention's `ConfirmPrompt`). A backdrop /
|
|
119
|
+
// Escape dismissal can only ever resolve the confirm as cancelled
|
|
120
|
+
// (`false`), never the destructive action — callers that need a truly
|
|
121
|
+
// blocking confirm opt out with `dismissible={false}`.
|
|
58
122
|
,
|
|
59
123
|
dismissOnBackdrop: dismissible,
|
|
60
|
-
maxWidth: ALERT_DIALOG_MAX_WIDTH,
|
|
61
124
|
title: title,
|
|
125
|
+
description: description,
|
|
126
|
+
actions: actions,
|
|
62
127
|
label: title,
|
|
63
128
|
style: cardStyle,
|
|
64
|
-
testID: testID
|
|
65
|
-
children: [description ? /*#__PURE__*/_jsx(Text, {
|
|
66
|
-
style: {
|
|
67
|
-
fontSize: fontSize.md,
|
|
68
|
-
lineHeight: fontSize.md * 1.4,
|
|
69
|
-
color: theme.colors.textSecondary,
|
|
70
|
-
paddingBottom: space.lg
|
|
71
|
-
},
|
|
72
|
-
children: description
|
|
73
|
-
}) : null, /*#__PURE__*/_jsxs(View, {
|
|
74
|
-
style: styles.actions,
|
|
75
|
-
children: [!hideCancel ? /*#__PURE__*/_jsx(Button, {
|
|
76
|
-
variant: "secondary",
|
|
77
|
-
size: "medium",
|
|
78
|
-
onPress: handleCancel,
|
|
79
|
-
style: styles.action,
|
|
80
|
-
accessibilityLabel: cancelLabel,
|
|
81
|
-
children: cancelLabel
|
|
82
|
-
}) : null, /*#__PURE__*/_jsx(Button, {
|
|
83
|
-
variant: "primary",
|
|
84
|
-
size: "medium",
|
|
85
|
-
onPress: handleConfirm,
|
|
86
|
-
style: StyleSheet.flatten([styles.action, destructive && {
|
|
87
|
-
backgroundColor: theme.colors.negative
|
|
88
|
-
}]),
|
|
89
|
-
textStyle: destructive ? {
|
|
90
|
-
color: theme.colors.negativeForeground
|
|
91
|
-
} : undefined,
|
|
92
|
-
accessibilityLabel: confirmLabel,
|
|
93
|
-
children: confirmLabel
|
|
94
|
-
})]
|
|
95
|
-
})]
|
|
129
|
+
testID: testID
|
|
96
130
|
});
|
|
97
131
|
};
|
|
98
132
|
const AlertDialog = /*#__PURE__*/memo(AlertDialogComponent);
|
|
99
133
|
AlertDialog.displayName = 'AlertDialog';
|
|
100
134
|
return AlertDialog;
|
|
101
135
|
}
|
|
102
|
-
const styles = StyleSheet.create({
|
|
103
|
-
actions: {
|
|
104
|
-
flexDirection: 'row',
|
|
105
|
-
justifyContent: 'flex-end',
|
|
106
|
-
gap: space.sm
|
|
107
|
-
},
|
|
108
|
-
action: {
|
|
109
|
-
minWidth: 96
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
136
|
//# sourceMappingURL=AlertDialog.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["React","memo","useCallback","
|
|
1
|
+
{"version":3,"names":["React","memo","useCallback","useEffect","useRef","useDialogControl","jsx","_jsx","createAlertDialog","Dialog","AlertDialogComponent","AlertDialog","visible","onClose","title","description","confirmLabel","cancelLabel","onConfirm","onCancel","destructive","hideCancel","dismissible","cardStyle","testID","control","closingFromPropRef","open","current","close","handleClose","actions","label","onPress","color","push","placement","dismissOnBackdrop","style","displayName"],"sourceRoot":"../../../src","sources":["alert-dialog/AlertDialog.tsx"],"mappings":";;AAAA,OAAOA,KAAK,IAAIC,IAAI,EAAEC,WAAW,EAAEC,SAAS,EAAEC,MAAM,QAAQ,OAAO;AAGnE,SAASC,gBAAgB,QAAQ,sBAAmB;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAKrD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,iBAAiBA,CAACC,MAAuB,EAAE;EACzD,MAAMC,oBAAoB,GAAG,SAASC,WAAWA,CAAC;IAChDC,OAAO;IACPC,OAAO;IACPC,KAAK;IACLC,WAAW;IACXC,YAAY,GAAG,SAAS;IACxBC,WAAW,GAAG,QAAQ;IACtBC,SAAS;IACTC,QAAQ;IACRC,WAAW,GAAG,KAAK;IACnBC,UAAU,GAAG,KAAK;IAClBC,WAAW,GAAG,IAAI;IAClBC,SAAS;IACTC;EACgB,CAAC,EAAE;IACnB,MAAMC,OAAO,GAAGpB,gBAAgB,CAAC,CAAC;;IAElC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,MAAMqB,kBAAkB,GAAGtB,MAAM,CAAC,KAAK,CAAC;IACxCD,SAAS,CAAC,MAAM;MACd,IAAIS,OAAO,EAAE;QACXa,OAAO,CAACE,IAAI,CAAC,CAAC;QACd;MACF;MACAD,kBAAkB,CAACE,OAAO,GAAG,IAAI;MACjCH,OAAO,CAACI,KAAK,CAAC,CAAC;IACjB,CAAC,EAAE,CAACjB,OAAO,EAAEa,OAAO,CAAC,CAAC;;IAEtB;IACA;IACA;IACA;IACA;IACA,MAAMK,WAAW,GAAG5B,WAAW,CAAC,MAAM;MACpC,IAAIwB,kBAAkB,CAACE,OAAO,EAAE;QAC9BF,kBAAkB,CAACE,OAAO,GAAG,KAAK;QAClC;MACF;MACAf,OAAO,CAAC,CAAC;IACX,CAAC,EAAE,CAACA,OAAO,CAAC,CAAC;;IAEb;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,MAAMkB,OAAuB,GAAG,CAC9B;MACEC,KAAK,EAAEhB,YAAY;MACnBiB,OAAO,EAAEf,SAAS;MAClBgB,KAAK,EAAEd,WAAW,GAAG,aAAa,GAAG;IACvC,CAAC,CACF;IACD,IAAI,CAACC,UAAU,EAAE;MACf;MACA;MACAU,OAAO,CAACI,IAAI,CAAC;QACXH,KAAK,EAAEf,WAAW;QAClBgB,OAAO,EAAEd,QAAQ;QACjBe,KAAK,EAAE;MACT,CAAC,CAAC;IACJ;IAEA,oBACE3B,IAAA,CAACE,MAAM;MACLgB,OAAO,EAAEA,OAAQ;MACjBZ,OAAO,EAAEiB,WAAY;MACrBM,SAAS,EAAC;MACV;MACA;MACA;MACA;MACA;MAAA;MACAC,iBAAiB,EAAEf,WAAY;MAC/BR,KAAK,EAAEA,KAAM;MACbC,WAAW,EAAEA,WAAY;MACzBgB,OAAO,EAAEA,OAAQ;MACjBC,KAAK,EAAElB,KAAM;MACbwB,KAAK,EAAEf,SAAU;MACjBC,MAAM,EAAEA;IAAO,CAChB,CAAC;EAEN,CAAC;EAED,MAAMb,WAAW,gBAAGV,IAAI,CAACS,oBAAoB,CAAC;EAC9CC,WAAW,CAAC4B,WAAW,GAAG,aAAa;EACvC,OAAO5B,WAAW;AACpB","ignoreList":[]}
|
|
@@ -11,10 +11,29 @@ type DialogComponent = React.ComponentType<DialogProps>;
|
|
|
11
11
|
* (`index.ts` / `index.web.ts`) inject the correct `Dialog` here. The body
|
|
12
12
|
* below is single-source, no duplication.
|
|
13
13
|
*
|
|
14
|
-
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that
|
|
15
|
-
*
|
|
14
|
+
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that maps
|
|
15
|
+
* the confirm/cancel props onto the Dialog's declarative `actions` row — the
|
|
16
|
+
* SAME battle-tested `ActionRow`/`ActionButton` primitive every other bloom
|
|
17
|
+
* confirm surface uses (e.g. Mention's `ConfirmPrompt`), so the button
|
|
18
|
+
* component, layout, and palette stay identical across apps. Reach for the
|
|
16
19
|
* imperative `confirm()` helper + `<AlertDialogHost />` to trigger a confirm
|
|
17
20
|
* from an event handler without owning visible state.
|
|
21
|
+
*
|
|
22
|
+
* Control mode — IMPERATIVE, not controlled. `AlertDialog` keeps its public
|
|
23
|
+
* *controlled* API (`visible` boolean + `onClose`) but internally bridges it
|
|
24
|
+
* onto the Dialog's imperative `useDialogControl()` handle, so `confirm()`
|
|
25
|
+
* drives `<Dialog>` through the EXACT uncontrolled/imperative internals
|
|
26
|
+
* Mention's `ConfirmPrompt` uses. That is the code path that actually plays the
|
|
27
|
+
* exit animation on dismiss: imperative `close()` sets `isClosing`, runs the
|
|
28
|
+
* exit animation, and fires `onClose` only once it settles. The controlled path
|
|
29
|
+
* instead fires `onClose` synchronously — and because `<AlertDialogHost />`
|
|
30
|
+
* resolves + drops the queue entry inside `onClose`, the surface would then
|
|
31
|
+
* unmount instantly, skipping the exit animation entirely. Bridging to
|
|
32
|
+
* imperative mode eliminates that fork so `confirm()` and Mention's
|
|
33
|
+
* `ConfirmPrompt` are pixel/timing identical.
|
|
34
|
+
*
|
|
35
|
+
* The centered card uses the Dialog default `maxWidth` (480px) for full visual
|
|
36
|
+
* parity with the shared confirm surface.
|
|
18
37
|
*/
|
|
19
38
|
export declare function createAlertDialog(Dialog: DialogComponent): React.MemoExoticComponent<({ visible, onClose, title, description, confirmLabel, cancelLabel, onConfirm, onCancel, destructive, hideCancel, dismissible, cardStyle, testID, }: AlertDialogProps) => import("react/jsx-runtime").JSX.Element>;
|
|
20
39
|
export type AlertDialogType = ReturnType<typeof createAlertDialog>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AlertDialog.d.ts","sourceRoot":"","sources":["../../../../src/alert-dialog/AlertDialog.tsx"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"AlertDialog.d.ts","sourceRoot":"","sources":["../../../../src/alert-dialog/AlertDialog.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA+C,MAAM,OAAO,CAAC;AAEpE,OAAO,KAAK,EAAgB,WAAW,EAAE,MAAM,WAAW,CAAC;AAE3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEhD,KAAK,eAAe,GAAG,KAAK,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,eAAe,kLAepD,gBAAgB,8CAsFpB;AAED,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
|
|
@@ -25,8 +25,10 @@ export interface AlertDialogProps {
|
|
|
25
25
|
/** Render only the confirm button (no cancel). Defaults to `false`. */
|
|
26
26
|
hideCancel?: boolean;
|
|
27
27
|
/**
|
|
28
|
-
* Tap-outside / Escape dismisses. Defaults to `
|
|
29
|
-
*
|
|
28
|
+
* Tap-outside / Escape dismisses. Defaults to `true` — matching the shared
|
|
29
|
+
* `Dialog` default and Mention's `ConfirmPrompt`. A backdrop / Escape
|
|
30
|
+
* dismissal always resolves the confirm as cancelled (`false`), so it is
|
|
31
|
+
* safe by default; pass `false` for a truly blocking confirm.
|
|
30
32
|
*/
|
|
31
33
|
dismissible?: boolean;
|
|
32
34
|
cardStyle?: StyleProp<ViewStyle>;
|
|
@@ -39,6 +41,10 @@ export interface ConfirmOptions {
|
|
|
39
41
|
cancelLabel?: string;
|
|
40
42
|
destructive?: boolean;
|
|
41
43
|
hideCancel?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Tap-outside / Escape dismisses (resolving `false`). Defaults to `true`;
|
|
46
|
+
* pass `false` for a blocking confirm that must be answered by a button.
|
|
47
|
+
*/
|
|
42
48
|
dismissible?: boolean;
|
|
43
49
|
}
|
|
44
50
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/alert-dialog/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD,MAAM,MAAM,sBAAsB,GAAG,SAAS,GAAG,QAAQ,GAAG,aAAa,CAAC;AAE1E,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,OAAO,EAAE,OAAO,CAAC;IACjB,sEAAsE;IACtE,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,gBAAgB;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uEAAuE;IACvE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/alert-dialog/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD,MAAM,MAAM,sBAAsB,GAAG,SAAS,GAAG,QAAQ,GAAG,aAAa,CAAC;AAE1E,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,OAAO,EAAE,OAAO,CAAC;IACjB,sEAAsE;IACtE,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,gBAAgB;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uEAAuE;IACvE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB"}
|
|
@@ -11,10 +11,29 @@ type DialogComponent = React.ComponentType<DialogProps>;
|
|
|
11
11
|
* (`index.ts` / `index.web.ts`) inject the correct `Dialog` here. The body
|
|
12
12
|
* below is single-source, no duplication.
|
|
13
13
|
*
|
|
14
|
-
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that
|
|
15
|
-
*
|
|
14
|
+
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that maps
|
|
15
|
+
* the confirm/cancel props onto the Dialog's declarative `actions` row — the
|
|
16
|
+
* SAME battle-tested `ActionRow`/`ActionButton` primitive every other bloom
|
|
17
|
+
* confirm surface uses (e.g. Mention's `ConfirmPrompt`), so the button
|
|
18
|
+
* component, layout, and palette stay identical across apps. Reach for the
|
|
16
19
|
* imperative `confirm()` helper + `<AlertDialogHost />` to trigger a confirm
|
|
17
20
|
* from an event handler without owning visible state.
|
|
21
|
+
*
|
|
22
|
+
* Control mode — IMPERATIVE, not controlled. `AlertDialog` keeps its public
|
|
23
|
+
* *controlled* API (`visible` boolean + `onClose`) but internally bridges it
|
|
24
|
+
* onto the Dialog's imperative `useDialogControl()` handle, so `confirm()`
|
|
25
|
+
* drives `<Dialog>` through the EXACT uncontrolled/imperative internals
|
|
26
|
+
* Mention's `ConfirmPrompt` uses. That is the code path that actually plays the
|
|
27
|
+
* exit animation on dismiss: imperative `close()` sets `isClosing`, runs the
|
|
28
|
+
* exit animation, and fires `onClose` only once it settles. The controlled path
|
|
29
|
+
* instead fires `onClose` synchronously — and because `<AlertDialogHost />`
|
|
30
|
+
* resolves + drops the queue entry inside `onClose`, the surface would then
|
|
31
|
+
* unmount instantly, skipping the exit animation entirely. Bridging to
|
|
32
|
+
* imperative mode eliminates that fork so `confirm()` and Mention's
|
|
33
|
+
* `ConfirmPrompt` are pixel/timing identical.
|
|
34
|
+
*
|
|
35
|
+
* The centered card uses the Dialog default `maxWidth` (480px) for full visual
|
|
36
|
+
* parity with the shared confirm surface.
|
|
18
37
|
*/
|
|
19
38
|
export declare function createAlertDialog(Dialog: DialogComponent): React.MemoExoticComponent<({ visible, onClose, title, description, confirmLabel, cancelLabel, onConfirm, onCancel, destructive, hideCancel, dismissible, cardStyle, testID, }: AlertDialogProps) => import("react/jsx-runtime").JSX.Element>;
|
|
20
39
|
export type AlertDialogType = ReturnType<typeof createAlertDialog>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AlertDialog.d.ts","sourceRoot":"","sources":["../../../../src/alert-dialog/AlertDialog.tsx"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"AlertDialog.d.ts","sourceRoot":"","sources":["../../../../src/alert-dialog/AlertDialog.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA+C,MAAM,OAAO,CAAC;AAEpE,OAAO,KAAK,EAAgB,WAAW,EAAE,MAAM,WAAW,CAAC;AAE3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEhD,KAAK,eAAe,GAAG,KAAK,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,eAAe,kLAepD,gBAAgB,8CAsFpB;AAED,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
|
|
@@ -25,8 +25,10 @@ export interface AlertDialogProps {
|
|
|
25
25
|
/** Render only the confirm button (no cancel). Defaults to `false`. */
|
|
26
26
|
hideCancel?: boolean;
|
|
27
27
|
/**
|
|
28
|
-
* Tap-outside / Escape dismisses. Defaults to `
|
|
29
|
-
*
|
|
28
|
+
* Tap-outside / Escape dismisses. Defaults to `true` — matching the shared
|
|
29
|
+
* `Dialog` default and Mention's `ConfirmPrompt`. A backdrop / Escape
|
|
30
|
+
* dismissal always resolves the confirm as cancelled (`false`), so it is
|
|
31
|
+
* safe by default; pass `false` for a truly blocking confirm.
|
|
30
32
|
*/
|
|
31
33
|
dismissible?: boolean;
|
|
32
34
|
cardStyle?: StyleProp<ViewStyle>;
|
|
@@ -39,6 +41,10 @@ export interface ConfirmOptions {
|
|
|
39
41
|
cancelLabel?: string;
|
|
40
42
|
destructive?: boolean;
|
|
41
43
|
hideCancel?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Tap-outside / Escape dismisses (resolving `false`). Defaults to `true`;
|
|
46
|
+
* pass `false` for a blocking confirm that must be answered by a button.
|
|
47
|
+
*/
|
|
42
48
|
dismissible?: boolean;
|
|
43
49
|
}
|
|
44
50
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/alert-dialog/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD,MAAM,MAAM,sBAAsB,GAAG,SAAS,GAAG,QAAQ,GAAG,aAAa,CAAC;AAE1E,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,OAAO,EAAE,OAAO,CAAC;IACjB,sEAAsE;IACtE,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,gBAAgB;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uEAAuE;IACvE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/alert-dialog/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD,MAAM,MAAM,sBAAsB,GAAG,SAAS,GAAG,QAAQ,GAAG,aAAa,CAAC;AAE1E,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,OAAO,EAAE,OAAO,CAAC;IACjB,sEAAsE;IACtE,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,gBAAgB;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uEAAuE;IACvE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
2
|
import { act, fireEvent, render } from '@testing-library/react-native';
|
|
3
3
|
|
|
4
|
-
import { AlertDialog } from '../alert-dialog';
|
|
4
|
+
import { AlertDialog, AlertDialogHost, confirm } from '../alert-dialog';
|
|
5
|
+
import { createAlertDialog } from '../alert-dialog/AlertDialog';
|
|
6
|
+
import { createAlertDialogHost } from '../alert-dialog/AlertDialogHost';
|
|
7
|
+
import { getConfirmQueue, resolveConfirm } from '../alert-dialog/confirm-store';
|
|
8
|
+
import type { DialogProps } from '../dialog';
|
|
5
9
|
import { BloomThemeProvider } from '../theme/BloomThemeProvider';
|
|
6
10
|
|
|
7
11
|
function renderWithTheme(ui: React.ReactElement) {
|
|
@@ -12,11 +16,23 @@ function renderWithTheme(ui: React.ReactElement) {
|
|
|
12
16
|
);
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
// `AlertDialog` is
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
+
// `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that maps
|
|
20
|
+
// its confirm/cancel props onto the Dialog's declarative `actions` row (the
|
|
21
|
+
// shared `ActionRow`/`ActionButton` primitive). These tests assert the
|
|
22
|
+
// public-API contract it owns — controlled visibility, the title/description
|
|
23
|
+
// copy, the confirm/cancel actions, and — most importantly — the exact
|
|
24
|
+
// `confirm()` promise-resolution contract every ecosystem call site relies on:
|
|
25
|
+
// the confirm button resolves `true`; the cancel button (or Escape / backdrop,
|
|
26
|
+
// which route through the Dialog's `onClose`) resolves `false`.
|
|
19
27
|
describe('AlertDialog (built on Dialog)', () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
// Drain any confirm() entries a test left pending so the module-scoped
|
|
30
|
+
// queue never bleeds across tests.
|
|
31
|
+
for (const entry of [...getConfirmQueue()]) {
|
|
32
|
+
resolveConfirm(entry.id, false);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
20
36
|
it('renders nothing when not visible', () => {
|
|
21
37
|
const { queryByText } = renderWithTheme(
|
|
22
38
|
<AlertDialog
|
|
@@ -48,8 +64,9 @@ describe('AlertDialog (built on Dialog)', () => {
|
|
|
48
64
|
});
|
|
49
65
|
|
|
50
66
|
it('runs onConfirm then onClose when the confirm button is pressed', () => {
|
|
51
|
-
const
|
|
52
|
-
const
|
|
67
|
+
const calls: string[] = [];
|
|
68
|
+
const onConfirm = jest.fn(() => calls.push('confirm'));
|
|
69
|
+
const onClose = jest.fn(() => calls.push('close'));
|
|
53
70
|
const { getByText } = renderWithTheme(
|
|
54
71
|
<AlertDialog
|
|
55
72
|
visible
|
|
@@ -64,10 +81,11 @@ describe('AlertDialog (built on Dialog)', () => {
|
|
|
64
81
|
});
|
|
65
82
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
66
83
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
84
|
+
// Order matters: onConfirm must resolve the promise BEFORE onClose runs.
|
|
85
|
+
expect(calls).toEqual(['confirm', 'close']);
|
|
67
86
|
});
|
|
68
87
|
|
|
69
|
-
it('
|
|
70
|
-
const onCancel = jest.fn();
|
|
88
|
+
it('requests close (resolves cancel) when the cancel button is pressed', () => {
|
|
71
89
|
const onClose = jest.fn();
|
|
72
90
|
const { getByText } = renderWithTheme(
|
|
73
91
|
<AlertDialog
|
|
@@ -75,16 +93,37 @@ describe('AlertDialog (built on Dialog)', () => {
|
|
|
75
93
|
onClose={onClose}
|
|
76
94
|
title="Confirm?"
|
|
77
95
|
cancelLabel="No"
|
|
78
|
-
onCancel={onCancel}
|
|
79
96
|
/>,
|
|
80
97
|
);
|
|
81
98
|
act(() => {
|
|
82
99
|
fireEvent.press(getByText('No'));
|
|
83
100
|
});
|
|
84
|
-
|
|
101
|
+
// The `cancel` action dismisses via the Dialog's `close()`, which in
|
|
102
|
+
// controlled mode fires `onClose` — the confirm() host's resolve-false.
|
|
85
103
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
86
104
|
});
|
|
87
105
|
|
|
106
|
+
it('invokes onCancel after the cancel button dismisses the dialog', () => {
|
|
107
|
+
const onCancel = jest.fn();
|
|
108
|
+
function Harness() {
|
|
109
|
+
const [open, setOpen] = useState(true);
|
|
110
|
+
return (
|
|
111
|
+
<AlertDialog
|
|
112
|
+
visible={open}
|
|
113
|
+
onClose={() => setOpen(false)}
|
|
114
|
+
title="Confirm?"
|
|
115
|
+
cancelLabel="No"
|
|
116
|
+
onCancel={onCancel}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const { getByText } = renderWithTheme(<Harness />);
|
|
121
|
+
act(() => {
|
|
122
|
+
fireEvent.press(getByText('No'));
|
|
123
|
+
});
|
|
124
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
88
127
|
it('hides the cancel button when hideCancel is set', () => {
|
|
89
128
|
const { queryByText, getByText } = renderWithTheme(
|
|
90
129
|
<AlertDialog
|
|
@@ -112,11 +151,134 @@ describe('AlertDialog (built on Dialog)', () => {
|
|
|
112
151
|
onConfirm={onConfirm}
|
|
113
152
|
/>,
|
|
114
153
|
);
|
|
115
|
-
const
|
|
116
|
-
expect(
|
|
154
|
+
const confirmButton = getByText('Sign out');
|
|
155
|
+
expect(confirmButton).toBeTruthy();
|
|
117
156
|
act(() => {
|
|
118
|
-
fireEvent.press(
|
|
157
|
+
fireEvent.press(confirmButton);
|
|
119
158
|
});
|
|
120
159
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
121
160
|
});
|
|
161
|
+
|
|
162
|
+
// The authoritative ecosystem contract: `confirm()` + `<AlertDialogHost />`.
|
|
163
|
+
// Every consuming call site does `const ok = await confirm({...})`, so these
|
|
164
|
+
// assert the exact promise resolution against the REAL rendered action row.
|
|
165
|
+
describe('confirm() + AlertDialogHost promise contract', () => {
|
|
166
|
+
it('resolves true when the confirm button is pressed', async () => {
|
|
167
|
+
const { getByText } = renderWithTheme(<AlertDialogHost />);
|
|
168
|
+
let result!: Promise<boolean>;
|
|
169
|
+
act(() => {
|
|
170
|
+
result = confirm({
|
|
171
|
+
title: 'Delete app?',
|
|
172
|
+
confirmLabel: 'Delete',
|
|
173
|
+
cancelLabel: 'Keep',
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
act(() => {
|
|
177
|
+
fireEvent.press(getByText('Delete'));
|
|
178
|
+
});
|
|
179
|
+
await expect(result).resolves.toBe(true);
|
|
180
|
+
expect(getConfirmQueue()).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('resolves false when the cancel button is pressed', async () => {
|
|
184
|
+
const { getByText } = renderWithTheme(<AlertDialogHost />);
|
|
185
|
+
let result!: Promise<boolean>;
|
|
186
|
+
act(() => {
|
|
187
|
+
result = confirm({
|
|
188
|
+
title: 'Discard changes?',
|
|
189
|
+
confirmLabel: 'Discard',
|
|
190
|
+
cancelLabel: 'Keep editing',
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
act(() => {
|
|
194
|
+
fireEvent.press(getByText('Keep editing'));
|
|
195
|
+
});
|
|
196
|
+
await expect(result).resolves.toBe(false);
|
|
197
|
+
expect(getConfirmQueue()).toHaveLength(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('resolves true even though confirm fires both onConfirm and onClose', async () => {
|
|
201
|
+
// The confirm button runs onConfirm (resolve true, drains the entry) then
|
|
202
|
+
// onClose (resolve false — a no-op once the entry is gone). The net must
|
|
203
|
+
// be `true`; this guards the double-resolution path explicitly.
|
|
204
|
+
const { getByText } = renderWithTheme(<AlertDialogHost />);
|
|
205
|
+
let result!: Promise<boolean>;
|
|
206
|
+
act(() => {
|
|
207
|
+
result = confirm({ title: 'Proceed?', confirmLabel: 'Proceed' });
|
|
208
|
+
});
|
|
209
|
+
act(() => {
|
|
210
|
+
fireEvent.press(getByText('Proceed'));
|
|
211
|
+
});
|
|
212
|
+
await expect(result).resolves.toBe(true);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Finding 1 (control-mode) + Finding 2 (backdrop-dismiss default). These pin
|
|
217
|
+
// the two behavioural fixes at the prop boundary between `AlertDialog` and the
|
|
218
|
+
// underlying `Dialog`, using a prop-capturing mock `Dialog` so the assertions
|
|
219
|
+
// are deterministic and platform-agnostic.
|
|
220
|
+
describe('control-mode bridge + dismiss defaults', () => {
|
|
221
|
+
function captureDialogProps() {
|
|
222
|
+
const captured: { props?: DialogProps } = {};
|
|
223
|
+
const MockDialog = (props: DialogProps) => {
|
|
224
|
+
captured.props = props;
|
|
225
|
+
return null;
|
|
226
|
+
};
|
|
227
|
+
return { captured, MockDialog };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
it('drives the underlying Dialog imperatively (control), never the controlled `open` prop', () => {
|
|
231
|
+
const { captured, MockDialog } = captureDialogProps();
|
|
232
|
+
const TestAlertDialog = createAlertDialog(MockDialog);
|
|
233
|
+
renderWithTheme(<TestAlertDialog visible onClose={() => {}} title="Delete?" />);
|
|
234
|
+
// The whole point of the fix: `confirm()` must take the Dialog's
|
|
235
|
+
// imperative/uncontrolled branch (the one that plays the exit animation),
|
|
236
|
+
// so an imperative `control` handle is passed and the controlled `open`
|
|
237
|
+
// boolean — which would take the other branch — is never set.
|
|
238
|
+
expect(captured.props?.control).toBeDefined();
|
|
239
|
+
expect(captured.props?.open).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('defaults dismissOnBackdrop to true (backdrop / Escape dismiss by default)', () => {
|
|
243
|
+
const { captured, MockDialog } = captureDialogProps();
|
|
244
|
+
const TestAlertDialog = createAlertDialog(MockDialog);
|
|
245
|
+
renderWithTheme(<TestAlertDialog visible onClose={() => {}} title="Delete?" />);
|
|
246
|
+
// Matches the shared Dialog default + Mention's ConfirmPrompt.
|
|
247
|
+
expect(captured.props?.dismissOnBackdrop).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('still supports an opt-in blocking confirm via dismissible={false}', () => {
|
|
251
|
+
const { captured, MockDialog } = captureDialogProps();
|
|
252
|
+
const TestAlertDialog = createAlertDialog(MockDialog);
|
|
253
|
+
renderWithTheme(
|
|
254
|
+
<TestAlertDialog visible onClose={() => {}} title="Delete?" dismissible={false} />,
|
|
255
|
+
);
|
|
256
|
+
expect(captured.props?.dismissOnBackdrop).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('resolves false when dismissed via backdrop / Escape (Dialog onClose) with no button press', async () => {
|
|
260
|
+
// Backdrop tap and Escape both route through the Dialog's `onClose`. Now
|
|
261
|
+
// that dismissOnBackdrop defaults true, that path is reachable by default
|
|
262
|
+
// and must resolve the confirm as cancelled (false) and drain the queue —
|
|
263
|
+
// never trigger the (potentially destructive) confirm action.
|
|
264
|
+
const captured: { onClose?: () => void } = {};
|
|
265
|
+
const MockDialog = (props: DialogProps) => {
|
|
266
|
+
captured.onClose = props.onClose;
|
|
267
|
+
return null;
|
|
268
|
+
};
|
|
269
|
+
const Host = createAlertDialogHost(createAlertDialog(MockDialog));
|
|
270
|
+
renderWithTheme(<Host />);
|
|
271
|
+
let result!: Promise<boolean>;
|
|
272
|
+
act(() => {
|
|
273
|
+
result = confirm({ title: 'Discard unsaved changes?' });
|
|
274
|
+
});
|
|
275
|
+
act(() => {
|
|
276
|
+
// Simulate the backdrop / Escape dismissal the real Dialog fires after
|
|
277
|
+
// its exit animation settles.
|
|
278
|
+
captured.onClose?.();
|
|
279
|
+
});
|
|
280
|
+
await expect(result).resolves.toBe(false);
|
|
281
|
+
expect(getConfirmQueue()).toHaveLength(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
122
284
|
});
|
|
@@ -42,6 +42,22 @@ describe('confirm-store', () => {
|
|
|
42
42
|
await expect(promise).resolves.toBe(false);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
it('resolves once — a second resolve for the same id is a no-op', async () => {
|
|
46
|
+
// The AlertDialog confirm path calls onConfirm (resolve true) THEN onClose
|
|
47
|
+
// (resolve false) for the SAME entry. The first resolve drains the entry,
|
|
48
|
+
// so the second is ignored and the promise keeps its `true` value. This is
|
|
49
|
+
// the guard the whole confirm() button contract relies on.
|
|
50
|
+
const promise = confirm({ title: 'Proceed?' });
|
|
51
|
+
const id = getConfirmQueue()[0]?.id;
|
|
52
|
+
expect(id).toBeTruthy();
|
|
53
|
+
if (id) {
|
|
54
|
+
resolveConfirm(id, true);
|
|
55
|
+
resolveConfirm(id, false);
|
|
56
|
+
}
|
|
57
|
+
await expect(promise).resolves.toBe(true);
|
|
58
|
+
expect(getConfirmQueue()).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
45
61
|
it('drains queued entries to a late subscriber', () => {
|
|
46
62
|
void confirm({ title: 'Queued before subscribe' });
|
|
47
63
|
let delivered: number | null = null;
|
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
import React, { memo, useCallback } from 'react';
|
|
2
|
-
import { StyleSheet, View } from 'react-native';
|
|
1
|
+
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
|
3
2
|
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import { useTheme } from '../theme/use-theme';
|
|
7
|
-
import { Text } from '../typography';
|
|
8
|
-
import { fontSize, space } from '../styles/tokens';
|
|
3
|
+
import type { DialogAction, DialogProps } from '../dialog';
|
|
4
|
+
import { useDialogControl } from '../dialog/context';
|
|
9
5
|
import type { AlertDialogProps } from './types';
|
|
10
6
|
|
|
11
7
|
type DialogComponent = React.ComponentType<DialogProps>;
|
|
12
8
|
|
|
13
|
-
/** Centered alert-card max width (px). Matches the legacy AlertDialog card. */
|
|
14
|
-
const ALERT_DIALOG_MAX_WIDTH = 440;
|
|
15
|
-
|
|
16
9
|
/**
|
|
17
10
|
* Build the `AlertDialog` component bound to a platform `Dialog`.
|
|
18
11
|
*
|
|
@@ -22,10 +15,29 @@ const ALERT_DIALOG_MAX_WIDTH = 440;
|
|
|
22
15
|
* (`index.ts` / `index.web.ts`) inject the correct `Dialog` here. The body
|
|
23
16
|
* below is single-source, no duplication.
|
|
24
17
|
*
|
|
25
|
-
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that
|
|
26
|
-
*
|
|
18
|
+
* `AlertDialog` is a thin wrapper over `<Dialog placement="center">` that maps
|
|
19
|
+
* the confirm/cancel props onto the Dialog's declarative `actions` row — the
|
|
20
|
+
* SAME battle-tested `ActionRow`/`ActionButton` primitive every other bloom
|
|
21
|
+
* confirm surface uses (e.g. Mention's `ConfirmPrompt`), so the button
|
|
22
|
+
* component, layout, and palette stay identical across apps. Reach for the
|
|
27
23
|
* imperative `confirm()` helper + `<AlertDialogHost />` to trigger a confirm
|
|
28
24
|
* from an event handler without owning visible state.
|
|
25
|
+
*
|
|
26
|
+
* Control mode — IMPERATIVE, not controlled. `AlertDialog` keeps its public
|
|
27
|
+
* *controlled* API (`visible` boolean + `onClose`) but internally bridges it
|
|
28
|
+
* onto the Dialog's imperative `useDialogControl()` handle, so `confirm()`
|
|
29
|
+
* drives `<Dialog>` through the EXACT uncontrolled/imperative internals
|
|
30
|
+
* Mention's `ConfirmPrompt` uses. That is the code path that actually plays the
|
|
31
|
+
* exit animation on dismiss: imperative `close()` sets `isClosing`, runs the
|
|
32
|
+
* exit animation, and fires `onClose` only once it settles. The controlled path
|
|
33
|
+
* instead fires `onClose` synchronously — and because `<AlertDialogHost />`
|
|
34
|
+
* resolves + drops the queue entry inside `onClose`, the surface would then
|
|
35
|
+
* unmount instantly, skipping the exit animation entirely. Bridging to
|
|
36
|
+
* imperative mode eliminates that fork so `confirm()` and Mention's
|
|
37
|
+
* `ConfirmPrompt` are pixel/timing identical.
|
|
38
|
+
*
|
|
39
|
+
* The centered card uses the Dialog default `maxWidth` (480px) for full visual
|
|
40
|
+
* parity with the shared confirm surface.
|
|
29
41
|
*/
|
|
30
42
|
export function createAlertDialog(Dialog: DialogComponent) {
|
|
31
43
|
const AlertDialogComponent = function AlertDialog({
|
|
@@ -39,75 +51,89 @@ export function createAlertDialog(Dialog: DialogComponent) {
|
|
|
39
51
|
onCancel,
|
|
40
52
|
destructive = false,
|
|
41
53
|
hideCancel = false,
|
|
42
|
-
dismissible =
|
|
54
|
+
dismissible = true,
|
|
43
55
|
cardStyle,
|
|
44
56
|
testID,
|
|
45
57
|
}: AlertDialogProps) {
|
|
46
|
-
const
|
|
58
|
+
const control = useDialogControl();
|
|
47
59
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
// Bridge the public *controlled* `visible` prop onto the Dialog's
|
|
61
|
+
// imperative open/close. Opening on mount straight from an effect (no
|
|
62
|
+
// `setTimeout`) mirrors bloom's own `AutoMountedDialog` (which backs
|
|
63
|
+
// `alert()`) — the canonical fresh-mount imperative-open pattern:
|
|
64
|
+
// `<AlertDialogHost />` mounts a FRESH `AlertDialog` per confirm
|
|
65
|
+
// (`key={id}`) with the final content already committed, so there is no
|
|
66
|
+
// stale-content window to defer past and the entry animation curve stays
|
|
67
|
+
// identical to Mention's. `control` is referentially stable (memoised on
|
|
68
|
+
// its id), so this effect only re-runs when `visible` actually flips. When
|
|
69
|
+
// a direct consumer flips `visible` to `false`, we imperatively `close()`
|
|
70
|
+
// so the exit animation still plays.
|
|
71
|
+
const closingFromPropRef = useRef(false);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (visible) {
|
|
74
|
+
control.open();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
closingFromPropRef.current = true;
|
|
78
|
+
control.close();
|
|
79
|
+
}, [visible, control]);
|
|
52
80
|
|
|
53
|
-
|
|
54
|
-
|
|
81
|
+
// The Dialog fires `onClose` after the exit animation settles (imperative
|
|
82
|
+
// mode). Forward ONLY user-initiated dismissals (backdrop / Escape / cancel)
|
|
83
|
+
// to the consumer — a consumer-initiated close (they flipped `visible` to
|
|
84
|
+
// `false`) must not re-enter `onClose`, preserving the previous controlled
|
|
85
|
+
// semantics where a programmatic close does not fire `onClose`.
|
|
86
|
+
const handleClose = useCallback(() => {
|
|
87
|
+
if (closingFromPropRef.current) {
|
|
88
|
+
closingFromPropRef.current = false;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
55
91
|
onClose();
|
|
56
|
-
}, [
|
|
92
|
+
}, [onClose]);
|
|
93
|
+
|
|
94
|
+
// Confirm/cancel map onto the Dialog's declarative action row. Both keep the
|
|
95
|
+
// default `shouldCloseOnPress` (true): the button runs the exit animation
|
|
96
|
+
// FIRST, then invokes `onPress` as a close-completion callback — so
|
|
97
|
+
// `onConfirm` resolves the `confirm()` promise as the surface animates out,
|
|
98
|
+
// exactly like Mention. `onConfirm` (resolve `true`, drains the entry) runs
|
|
99
|
+
// just BEFORE the Dialog's own post-close `onClose` (→ `handleClose` →
|
|
100
|
+
// resolve `false`, now a no-op for the drained entry), which is what makes
|
|
101
|
+
// the confirm button win the double-resolution guard.
|
|
102
|
+
const actions: DialogAction[] = [
|
|
103
|
+
{
|
|
104
|
+
label: confirmLabel,
|
|
105
|
+
onPress: onConfirm,
|
|
106
|
+
color: destructive ? 'destructive' : 'default',
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
if (!hideCancel) {
|
|
110
|
+
// The `cancel` color auto-dismisses via the Dialog's `close()` (exit
|
|
111
|
+
// animation), then runs `onCancel` as a close-completion callback.
|
|
112
|
+
actions.push({
|
|
113
|
+
label: cancelLabel,
|
|
114
|
+
onPress: onCancel,
|
|
115
|
+
color: 'cancel',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
57
118
|
|
|
58
119
|
return (
|
|
59
120
|
<Dialog
|
|
60
|
-
|
|
61
|
-
onClose={
|
|
121
|
+
control={control}
|
|
122
|
+
onClose={handleClose}
|
|
62
123
|
placement="center"
|
|
63
|
-
// Alert dialogs
|
|
64
|
-
//
|
|
124
|
+
// Alert dialogs dismiss on backdrop / Escape by default (matching the
|
|
125
|
+
// shared Dialog default and Mention's `ConfirmPrompt`). A backdrop /
|
|
126
|
+
// Escape dismissal can only ever resolve the confirm as cancelled
|
|
127
|
+
// (`false`), never the destructive action — callers that need a truly
|
|
128
|
+
// blocking confirm opt out with `dismissible={false}`.
|
|
65
129
|
dismissOnBackdrop={dismissible}
|
|
66
|
-
maxWidth={ALERT_DIALOG_MAX_WIDTH}
|
|
67
130
|
title={title}
|
|
131
|
+
description={description}
|
|
132
|
+
actions={actions}
|
|
68
133
|
label={title}
|
|
69
134
|
style={cardStyle}
|
|
70
135
|
testID={testID}
|
|
71
|
-
|
|
72
|
-
{description ? (
|
|
73
|
-
<Text
|
|
74
|
-
style={{
|
|
75
|
-
fontSize: fontSize.md,
|
|
76
|
-
lineHeight: fontSize.md * 1.4,
|
|
77
|
-
color: theme.colors.textSecondary,
|
|
78
|
-
paddingBottom: space.lg,
|
|
79
|
-
}}
|
|
80
|
-
>
|
|
81
|
-
{description}
|
|
82
|
-
</Text>
|
|
83
|
-
) : null}
|
|
84
|
-
<View style={styles.actions}>
|
|
85
|
-
{!hideCancel ? (
|
|
86
|
-
<Button
|
|
87
|
-
variant="secondary"
|
|
88
|
-
size="medium"
|
|
89
|
-
onPress={handleCancel}
|
|
90
|
-
style={styles.action}
|
|
91
|
-
accessibilityLabel={cancelLabel}
|
|
92
|
-
>
|
|
93
|
-
{cancelLabel}
|
|
94
|
-
</Button>
|
|
95
|
-
) : null}
|
|
96
|
-
<Button
|
|
97
|
-
variant="primary"
|
|
98
|
-
size="medium"
|
|
99
|
-
onPress={handleConfirm}
|
|
100
|
-
style={StyleSheet.flatten([
|
|
101
|
-
styles.action,
|
|
102
|
-
destructive && { backgroundColor: theme.colors.negative },
|
|
103
|
-
])}
|
|
104
|
-
textStyle={destructive ? { color: theme.colors.negativeForeground } : undefined}
|
|
105
|
-
accessibilityLabel={confirmLabel}
|
|
106
|
-
>
|
|
107
|
-
{confirmLabel}
|
|
108
|
-
</Button>
|
|
109
|
-
</View>
|
|
110
|
-
</Dialog>
|
|
136
|
+
/>
|
|
111
137
|
);
|
|
112
138
|
};
|
|
113
139
|
|
|
@@ -117,14 +143,3 @@ export function createAlertDialog(Dialog: DialogComponent) {
|
|
|
117
143
|
}
|
|
118
144
|
|
|
119
145
|
export type AlertDialogType = ReturnType<typeof createAlertDialog>;
|
|
120
|
-
|
|
121
|
-
const styles = StyleSheet.create({
|
|
122
|
-
actions: {
|
|
123
|
-
flexDirection: 'row',
|
|
124
|
-
justifyContent: 'flex-end',
|
|
125
|
-
gap: space.sm,
|
|
126
|
-
},
|
|
127
|
-
action: {
|
|
128
|
-
minWidth: 96,
|
|
129
|
-
},
|
|
130
|
-
});
|
|
@@ -27,8 +27,10 @@ export interface AlertDialogProps {
|
|
|
27
27
|
/** Render only the confirm button (no cancel). Defaults to `false`. */
|
|
28
28
|
hideCancel?: boolean;
|
|
29
29
|
/**
|
|
30
|
-
* Tap-outside / Escape dismisses. Defaults to `
|
|
31
|
-
*
|
|
30
|
+
* Tap-outside / Escape dismisses. Defaults to `true` — matching the shared
|
|
31
|
+
* `Dialog` default and Mention's `ConfirmPrompt`. A backdrop / Escape
|
|
32
|
+
* dismissal always resolves the confirm as cancelled (`false`), so it is
|
|
33
|
+
* safe by default; pass `false` for a truly blocking confirm.
|
|
32
34
|
*/
|
|
33
35
|
dismissible?: boolean;
|
|
34
36
|
cardStyle?: StyleProp<ViewStyle>;
|
|
@@ -42,5 +44,9 @@ export interface ConfirmOptions {
|
|
|
42
44
|
cancelLabel?: string;
|
|
43
45
|
destructive?: boolean;
|
|
44
46
|
hideCancel?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Tap-outside / Escape dismisses (resolving `false`). Defaults to `true`;
|
|
49
|
+
* pass `false` for a blocking confirm that must be answered by a button.
|
|
50
|
+
*/
|
|
45
51
|
dismissible?: boolean;
|
|
46
52
|
}
|