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