@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.
@@ -5,16 +5,9 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.createAlertDialog = createAlertDialog;
7
7
  var _react = _interopRequireWildcard(require("react"));
8
- var _reactNative = require("react-native");
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 lays
28
- * out a title, description and a confirm/cancel action row. Reach for the
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 = false,
56
+ dismissible = true,
45
57
  cardStyle,
46
58
  testID
47
59
  }) {
48
- const theme = (0, _useTheme.useTheme)();
49
- const handleCancel = (0, _react.useCallback)(() => {
50
- onCancel?.();
51
- onClose();
52
- }, [onCancel, onClose]);
53
- const handleConfirm = (0, _react.useCallback)(() => {
54
- onConfirm?.();
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
- }, [onConfirm, onClose]);
57
- return /*#__PURE__*/(0, _jsxRuntime.jsxs)(Dialog, {
58
- open: visible,
59
- onClose: onClose,
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 are blocking by default the backdrop only dismisses
62
- // when the caller opts in via `dismissible`.
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","_reactNative","_button","_useTheme","_index","_tokens","_jsxRuntime","e","t","WeakMap","r","n","__esModule","o","i","f","__proto__","default","has","get","set","hasOwnProperty","call","Object","defineProperty","getOwnPropertyDescriptor","ALERT_DIALOG_MAX_WIDTH","createAlertDialog","Dialog","AlertDialogComponent","AlertDialog","visible","onClose","title","description","confirmLabel","cancelLabel","onConfirm","onCancel","destructive","hideCancel","dismissible","cardStyle","testID","theme","useTheme","handleCancel","useCallback","handleConfirm","jsxs","open","placement","dismissOnBackdrop","maxWidth","label","style","children","jsx","Text","fontSize","md","lineHeight","color","colors","textSecondary","paddingBottom","space","lg","View","styles","actions","Button","variant","size","onPress","action","accessibilityLabel","StyleSheet","flatten","backgroundColor","negative","textStyle","negativeForeground","undefined","memo","displayName","create","flexDirection","justifyContent","gap","sm","minWidth"],"sourceRoot":"../../../src","sources":["alert-dialog/AlertDialog.tsx"],"mappings":"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","ignoreList":[]}
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 { StyleSheet, View } from 'react-native';
5
- import { Button } from '../button';
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 lays
23
- * out a title, description and a confirm/cancel action row. Reach for the
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 = false,
51
+ dismissible = true,
40
52
  cardStyle,
41
53
  testID
42
54
  }) {
43
- const theme = useTheme();
44
- const handleCancel = useCallback(() => {
45
- onCancel?.();
46
- onClose();
47
- }, [onCancel, onClose]);
48
- const handleConfirm = useCallback(() => {
49
- onConfirm?.();
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
- }, [onConfirm, onClose]);
52
- return /*#__PURE__*/_jsxs(Dialog, {
53
- open: visible,
54
- onClose: onClose,
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 are blocking by default the backdrop only dismisses
57
- // when the caller opts in via `dismissible`.
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","StyleSheet","View","Button","useTheme","Text","fontSize","space","jsx","_jsx","jsxs","_jsxs","ALERT_DIALOG_MAX_WIDTH","createAlertDialog","Dialog","AlertDialogComponent","AlertDialog","visible","onClose","title","description","confirmLabel","cancelLabel","onConfirm","onCancel","destructive","hideCancel","dismissible","cardStyle","testID","theme","handleCancel","handleConfirm","open","placement","dismissOnBackdrop","maxWidth","label","style","children","md","lineHeight","color","colors","textSecondary","paddingBottom","lg","styles","actions","variant","size","onPress","action","accessibilityLabel","flatten","backgroundColor","negative","textStyle","negativeForeground","undefined","displayName","create","flexDirection","justifyContent","gap","sm","minWidth"],"sourceRoot":"../../../src","sources":["alert-dialog/AlertDialog.tsx"],"mappings":"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","ignoreList":[]}
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 lays
15
- * out a title, description and a confirm/cancel action row. Reach for the
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,KAA4B,MAAM,OAAO,CAAC;AAIjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAI7C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEhD,KAAK,eAAe,GAAG,KAAK,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC;AAKxD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,eAAe,kLAepD,gBAAgB,8CAwEpB;AAED,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
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 `false` — alert dialogs are
29
- * usually blocking until answered.
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;;;OAGG;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,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB"}
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 lays
15
- * out a title, description and a confirm/cancel action row. Reach for the
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,KAA4B,MAAM,OAAO,CAAC;AAIjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAI7C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEhD,KAAK,eAAe,GAAG,KAAK,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC;AAKxD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,eAAe,kLAepD,gBAAgB,8CAwEpB;AAED,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
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 `false` — alert dialogs are
29
- * usually blocking until answered.
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;;;OAGG;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,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB"}
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.30.10",
3
+ "version": "0.32.0",
4
4
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -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 now a thin wrapper over `<Dialog placement="center">`. These
16
- // tests assert the public-API contract it owns (controlled visibility, the
17
- // title/description copy, the confirm/cancel action row, and the
18
- // confirm/cancel/dismiss callbacks) holds on the native Dialog surface.
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 onConfirm = jest.fn();
52
- const onClose = jest.fn();
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('runs onCancel then onClose when the cancel button is pressed', () => {
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
- expect(onCancel).toHaveBeenCalledTimes(1);
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 confirm = getByText('Sign out');
116
- expect(confirm).toBeTruthy();
154
+ const confirmButton = getByText('Sign out');
155
+ expect(confirmButton).toBeTruthy();
117
156
  act(() => {
118
- fireEvent.press(confirm);
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 { Button } from '../button';
5
- import type { DialogProps } from '../dialog';
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 lays
26
- * out a title, description and a confirm/cancel action row. Reach for the
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 = false,
54
+ dismissible = true,
43
55
  cardStyle,
44
56
  testID,
45
57
  }: AlertDialogProps) {
46
- const theme = useTheme();
58
+ const control = useDialogControl();
47
59
 
48
- const handleCancel = useCallback(() => {
49
- onCancel?.();
50
- onClose();
51
- }, [onCancel, onClose]);
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
- const handleConfirm = useCallback(() => {
54
- onConfirm?.();
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
- }, [onConfirm, onClose]);
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
- open={visible}
61
- onClose={onClose}
121
+ control={control}
122
+ onClose={handleClose}
62
123
  placement="center"
63
- // Alert dialogs are blocking by default the backdrop only dismisses
64
- // when the caller opts in via `dismissible`.
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 `false` — alert dialogs are
31
- * usually blocking until answered.
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
  }