@oxyhq/bloom 0.3.12 → 0.5.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 (191) hide show
  1. package/README.md +133 -90
  2. package/lib/commonjs/bottom-sheet/index.js +341 -98
  3. package/lib/commonjs/bottom-sheet/index.js.map +1 -1
  4. package/lib/commonjs/context-menu/index.js +18 -19
  5. package/lib/commonjs/context-menu/index.js.map +1 -1
  6. package/lib/commonjs/dialog/BloomDialogProvider.js +61 -0
  7. package/lib/commonjs/dialog/BloomDialogProvider.js.map +1 -0
  8. package/lib/commonjs/dialog/BloomDialogProvider.web.js +45 -0
  9. package/lib/commonjs/dialog/BloomDialogProvider.web.js.map +1 -0
  10. package/lib/commonjs/dialog/Dialog.js +197 -100
  11. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  12. package/lib/commonjs/dialog/Dialog.web.js +194 -84
  13. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  14. package/lib/commonjs/dialog/SheetShell.js +149 -0
  15. package/lib/commonjs/dialog/SheetShell.js.map +1 -0
  16. package/lib/commonjs/dialog/alert-store.js +116 -0
  17. package/lib/commonjs/dialog/alert-store.js.map +1 -0
  18. package/lib/commonjs/dialog/alert.js +38 -0
  19. package/lib/commonjs/dialog/alert.js.map +1 -0
  20. package/lib/commonjs/dialog/context.js +10 -2
  21. package/lib/commonjs/dialog/context.js.map +1 -1
  22. package/lib/commonjs/dialog/index.js +8 -24
  23. package/lib/commonjs/dialog/index.js.map +1 -1
  24. package/lib/commonjs/dialog/index.web.js +10 -20
  25. package/lib/commonjs/dialog/index.web.js.map +1 -1
  26. package/lib/commonjs/index.js +101 -66
  27. package/lib/commonjs/index.js.map +1 -1
  28. package/lib/commonjs/index.web.js +101 -66
  29. package/lib/commonjs/index.web.js.map +1 -1
  30. package/lib/commonjs/menu/index.js +21 -23
  31. package/lib/commonjs/menu/index.js.map +1 -1
  32. package/lib/commonjs/select/index.js +26 -27
  33. package/lib/commonjs/select/index.js.map +1 -1
  34. package/lib/commonjs/toast/index.js +42 -13
  35. package/lib/commonjs/toast/index.js.map +1 -1
  36. package/lib/commonjs/toast/index.web.js +19 -15
  37. package/lib/commonjs/toast/index.web.js.map +1 -1
  38. package/lib/module/bottom-sheet/index.js +341 -98
  39. package/lib/module/bottom-sheet/index.js.map +1 -1
  40. package/lib/module/context-menu/index.js +15 -16
  41. package/lib/module/context-menu/index.js.map +1 -1
  42. package/lib/module/dialog/BloomDialogProvider.js +57 -0
  43. package/lib/module/dialog/BloomDialogProvider.js.map +1 -0
  44. package/lib/module/dialog/BloomDialogProvider.web.js +41 -0
  45. package/lib/module/dialog/BloomDialogProvider.web.js.map +1 -0
  46. package/lib/module/dialog/Dialog.js +199 -87
  47. package/lib/module/dialog/Dialog.js.map +1 -1
  48. package/lib/module/dialog/Dialog.web.js +195 -70
  49. package/lib/module/dialog/Dialog.web.js.map +1 -1
  50. package/lib/module/dialog/SheetShell.js +143 -0
  51. package/lib/module/dialog/SheetShell.js.map +1 -0
  52. package/lib/module/dialog/alert-store.js +107 -0
  53. package/lib/module/dialog/alert-store.js.map +1 -0
  54. package/lib/module/dialog/alert.js +35 -0
  55. package/lib/module/dialog/alert.js.map +1 -0
  56. package/lib/module/dialog/context.js +10 -2
  57. package/lib/module/dialog/context.js.map +1 -1
  58. package/lib/module/dialog/index.js +3 -1
  59. package/lib/module/dialog/index.js.map +1 -1
  60. package/lib/module/dialog/index.web.js +9 -7
  61. package/lib/module/dialog/index.web.js.map +1 -1
  62. package/lib/module/index.js +2 -3
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/index.web.js +2 -3
  65. package/lib/module/index.web.js.map +1 -1
  66. package/lib/module/menu/index.js +11 -13
  67. package/lib/module/menu/index.js.map +1 -1
  68. package/lib/module/select/index.js +27 -28
  69. package/lib/module/select/index.js.map +1 -1
  70. package/lib/module/toast/index.js +41 -11
  71. package/lib/module/toast/index.js.map +1 -1
  72. package/lib/module/toast/index.web.js +18 -13
  73. package/lib/module/toast/index.web.js.map +1 -1
  74. package/lib/typescript/commonjs/__tests__/Dialog.test.d.ts +2 -0
  75. package/lib/typescript/commonjs/__tests__/Dialog.test.d.ts.map +1 -0
  76. package/lib/typescript/commonjs/bottom-sheet/index.d.ts +47 -1
  77. package/lib/typescript/commonjs/bottom-sheet/index.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/context-menu/index.d.ts +4 -3
  79. package/lib/typescript/commonjs/context-menu/index.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/dialog/BloomDialogProvider.d.ts +27 -0
  81. package/lib/typescript/commonjs/dialog/BloomDialogProvider.d.ts.map +1 -0
  82. package/lib/typescript/commonjs/dialog/BloomDialogProvider.web.d.ts +15 -0
  83. package/lib/typescript/commonjs/dialog/BloomDialogProvider.web.d.ts.map +1 -0
  84. package/lib/typescript/commonjs/dialog/Dialog.d.ts +37 -10
  85. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +26 -10
  87. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/dialog/SheetShell.d.ts +31 -0
  89. package/lib/typescript/commonjs/dialog/SheetShell.d.ts.map +1 -0
  90. package/lib/typescript/commonjs/dialog/alert-store.d.ts +70 -0
  91. package/lib/typescript/commonjs/dialog/alert-store.d.ts.map +1 -0
  92. package/lib/typescript/commonjs/dialog/alert.d.ts +27 -0
  93. package/lib/typescript/commonjs/dialog/alert.d.ts.map +1 -0
  94. package/lib/typescript/commonjs/dialog/context.d.ts +7 -0
  95. package/lib/typescript/commonjs/dialog/context.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/dialog/index.d.ts +5 -2
  97. package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/dialog/index.web.d.ts +5 -2
  99. package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/dialog/types.d.ts +70 -15
  101. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/index.d.ts +3 -3
  103. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/index.web.d.ts +3 -3
  105. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/menu/index.d.ts +4 -4
  107. package/lib/typescript/commonjs/menu/index.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/select/index.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/toast/index.d.ts +32 -3
  110. package/lib/typescript/commonjs/toast/index.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/toast/index.web.d.ts +14 -7
  112. package/lib/typescript/commonjs/toast/index.web.d.ts.map +1 -1
  113. package/lib/typescript/module/__tests__/Dialog.test.d.ts +2 -0
  114. package/lib/typescript/module/__tests__/Dialog.test.d.ts.map +1 -0
  115. package/lib/typescript/module/bottom-sheet/index.d.ts +47 -1
  116. package/lib/typescript/module/bottom-sheet/index.d.ts.map +1 -1
  117. package/lib/typescript/module/context-menu/index.d.ts +4 -3
  118. package/lib/typescript/module/context-menu/index.d.ts.map +1 -1
  119. package/lib/typescript/module/dialog/BloomDialogProvider.d.ts +27 -0
  120. package/lib/typescript/module/dialog/BloomDialogProvider.d.ts.map +1 -0
  121. package/lib/typescript/module/dialog/BloomDialogProvider.web.d.ts +15 -0
  122. package/lib/typescript/module/dialog/BloomDialogProvider.web.d.ts.map +1 -0
  123. package/lib/typescript/module/dialog/Dialog.d.ts +37 -10
  124. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  125. package/lib/typescript/module/dialog/Dialog.web.d.ts +26 -10
  126. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  127. package/lib/typescript/module/dialog/SheetShell.d.ts +31 -0
  128. package/lib/typescript/module/dialog/SheetShell.d.ts.map +1 -0
  129. package/lib/typescript/module/dialog/alert-store.d.ts +70 -0
  130. package/lib/typescript/module/dialog/alert-store.d.ts.map +1 -0
  131. package/lib/typescript/module/dialog/alert.d.ts +27 -0
  132. package/lib/typescript/module/dialog/alert.d.ts.map +1 -0
  133. package/lib/typescript/module/dialog/context.d.ts +7 -0
  134. package/lib/typescript/module/dialog/context.d.ts.map +1 -1
  135. package/lib/typescript/module/dialog/index.d.ts +5 -2
  136. package/lib/typescript/module/dialog/index.d.ts.map +1 -1
  137. package/lib/typescript/module/dialog/index.web.d.ts +5 -2
  138. package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
  139. package/lib/typescript/module/dialog/types.d.ts +70 -15
  140. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  141. package/lib/typescript/module/index.d.ts +3 -3
  142. package/lib/typescript/module/index.d.ts.map +1 -1
  143. package/lib/typescript/module/index.web.d.ts +3 -3
  144. package/lib/typescript/module/index.web.d.ts.map +1 -1
  145. package/lib/typescript/module/menu/index.d.ts +4 -4
  146. package/lib/typescript/module/menu/index.d.ts.map +1 -1
  147. package/lib/typescript/module/select/index.d.ts.map +1 -1
  148. package/lib/typescript/module/toast/index.d.ts +32 -3
  149. package/lib/typescript/module/toast/index.d.ts.map +1 -1
  150. package/lib/typescript/module/toast/index.web.d.ts +14 -7
  151. package/lib/typescript/module/toast/index.web.d.ts.map +1 -1
  152. package/package.json +3 -14
  153. package/src/__tests__/BottomSheet.test.tsx +149 -2
  154. package/src/__tests__/Dialog.test.tsx +177 -0
  155. package/src/bottom-sheet/index.tsx +367 -83
  156. package/src/context-menu/index.tsx +12 -12
  157. package/src/dialog/BloomDialogProvider.tsx +61 -0
  158. package/src/dialog/BloomDialogProvider.web.tsx +46 -0
  159. package/src/dialog/Dialog.tsx +217 -64
  160. package/src/dialog/Dialog.web.tsx +240 -75
  161. package/src/dialog/SheetShell.tsx +154 -0
  162. package/src/dialog/alert-store.ts +126 -0
  163. package/src/dialog/alert.ts +42 -0
  164. package/src/dialog/context.ts +14 -3
  165. package/src/dialog/index.ts +14 -2
  166. package/src/dialog/index.web.ts +20 -8
  167. package/src/dialog/types.ts +73 -16
  168. package/src/index.ts +17 -3
  169. package/src/index.web.ts +17 -3
  170. package/src/menu/index.tsx +13 -17
  171. package/src/select/index.tsx +30 -30
  172. package/src/toast/index.tsx +55 -11
  173. package/src/toast/index.web.tsx +33 -13
  174. package/lib/commonjs/prompt/Prompt.js +0 -267
  175. package/lib/commonjs/prompt/Prompt.js.map +0 -1
  176. package/lib/commonjs/prompt/index.js +0 -61
  177. package/lib/commonjs/prompt/index.js.map +0 -1
  178. package/lib/module/prompt/Prompt.js +0 -250
  179. package/lib/module/prompt/Prompt.js.map +0 -1
  180. package/lib/module/prompt/index.js +0 -4
  181. package/lib/module/prompt/index.js.map +0 -1
  182. package/lib/typescript/commonjs/prompt/Prompt.d.ts +0 -42
  183. package/lib/typescript/commonjs/prompt/Prompt.d.ts.map +0 -1
  184. package/lib/typescript/commonjs/prompt/index.d.ts +0 -3
  185. package/lib/typescript/commonjs/prompt/index.d.ts.map +0 -1
  186. package/lib/typescript/module/prompt/Prompt.d.ts +0 -42
  187. package/lib/typescript/module/prompt/Prompt.d.ts.map +0 -1
  188. package/lib/typescript/module/prompt/index.d.ts +0 -3
  189. package/lib/typescript/module/prompt/index.d.ts.map +0 -1
  190. package/src/prompt/Prompt.tsx +0 -247
  191. package/src/prompt/index.ts +0 -13
@@ -10,7 +10,7 @@ import {
10
10
  type ViewStyle,
11
11
  type StyleProp,
12
12
  } from 'react-native';
13
- import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
13
+ import { Gesture, GestureDetector, GestureHandlerRootView, type GestureType } from 'react-native-gesture-handler';
14
14
  import Animated, {
15
15
  interpolate,
16
16
  runOnJS,
@@ -89,10 +89,56 @@ export interface BottomSheetProps {
89
89
  /**
90
90
  * Opacity of the dimming backdrop behind the sheet (0–1). Defaults to `0.5`.
91
91
  * Set to a higher value (e.g. `0.7`) when the sheet is presented over another
92
- * bottom sheet (Dialog/Prompt cases) so the underlying handle/content does not
92
+ * bottom sheet (Dialog cases) so the underlying handle/content does not
93
93
  * bleed through.
94
94
  */
95
95
  backdropOpacity?: number;
96
+ /**
97
+ * When `true` (default), children are wrapped in an internal scrollable
98
+ * container — convenient for vertical content that can overflow.
99
+ *
100
+ * Set to `false` when the screen owns its own scrolling primitive
101
+ * (e.g. a `FlatList`, `SectionList`, or any other VirtualizedList).
102
+ * Nesting a VirtualizedList inside the internal ScrollView would break
103
+ * windowing/keyboard handling and trigger a React Native warning. In
104
+ * non-scrollable mode the screen receives the full available height
105
+ * (minus the drag handle) and must manage its own overflow.
106
+ */
107
+ scrollable?: boolean;
108
+ /**
109
+ * When `true`, the body pan uses RNGH's `manualActivation` and only
110
+ * activates when (a) the inner ScrollView is at the top AND (b) the user
111
+ * has moved their finger downward by > 8dp. This is the @gorhom/bottom-sheet
112
+ * coordination model — recommended for sheets containing scrollable content
113
+ * on Android (avoids stealing vertical events from the inner scroller).
114
+ *
115
+ * When `false` (default), the body pan is always active and gates on the
116
+ * scroll offset at `onStart` time. This is the legacy behavior, preserved
117
+ * for backwards compatibility with current bloom consumers.
118
+ *
119
+ * Enabling this also splits the drag handle into its own dedicated,
120
+ * unconditional pan so users can always grab the handle to drag — even
121
+ * when the inner ScrollView is mid-scroll.
122
+ */
123
+ manualActivation?: boolean;
124
+ /**
125
+ * When `true`, the backdrop dims proportionally with drag distance — the
126
+ * overlay fades from full opacity (sheet at rest) to 30% as the sheet is
127
+ * pulled down 40% of the screen height. iOS Photos style. The base
128
+ * `backdropOpacity` still controls the resting dim level.
129
+ *
130
+ * Defaults to `false` (constant opacity during drag).
131
+ */
132
+ dynamicBackdrop?: boolean;
133
+ /**
134
+ * Custom handle slot. When provided, replaces the default drag handle
135
+ * (the 36×5 pill). The rendered handle is wrapped in the dedicated handle
136
+ * gesture detector (when `manualActivation` is `true`) so it remains
137
+ * unconditionally draggable. `showHandle={false}` still suppresses any
138
+ * handle rendering — `handleComponent` is only consulted when
139
+ * `showHandle` is `true`.
140
+ */
141
+ handleComponent?: () => React.ReactNode;
96
142
  }
97
143
 
98
144
  const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef<BottomSheetRef>) => {
@@ -108,6 +154,10 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
108
154
  detached = false,
109
155
  showHandle = true,
110
156
  backdropOpacity = 0.5,
157
+ scrollable = true,
158
+ manualActivation = false,
159
+ dynamicBackdrop = false,
160
+ handleComponent,
111
161
  } = props;
112
162
 
113
163
  const insets = useSafeAreaInsets();
@@ -119,6 +169,16 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
119
169
  const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
120
170
  const hasClosedRef = useRef(false);
121
171
  const scrollViewRef = useRef<Animated.ScrollView>(null);
172
+ /**
173
+ * Monotonically increasing counter that identifies "the current close
174
+ * attempt". Bumped every time the sheet re-opens (`present()`), so any
175
+ * in-flight `withTiming` completion callback or fallback timer from a
176
+ * PREVIOUS close cycle becomes a no-op. Without this guard, a stale
177
+ * `runOnJS(finishClose)` from an aborted close would fire `onDismiss`
178
+ * and unmount the sheet immediately after the user opens it again,
179
+ * causing "tap to open does nothing" reports in production.
180
+ */
181
+ const closeGenerationRef = useRef(0);
122
182
 
123
183
  const screenHeightSV = useSharedValue(screenHeight);
124
184
  // Keep shared value in sync when screen dimensions change (rotation/resize)
@@ -133,6 +193,21 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
133
193
  const allowPanClose = useSharedValue(true);
134
194
  const keyboardHeight = useSharedValue(0);
135
195
  const context = useSharedValue({ y: 0 });
196
+ // Mirror of `closeGenerationRef` for worklet access. Bumped from the JS
197
+ // thread in lockstep with the ref so gesture worklets always see the
198
+ // current generation when they snapshot it on `onEnd`.
199
+ const closeGeneration = useSharedValue(0);
200
+ // Used by `manualActivation` body pan to track the touch's initial Y so
201
+ // it can compute downward distance for the activation decision.
202
+ const touchStartY = useSharedValue(0);
203
+
204
+ // Refs used to mark the handle pan and the body pan as mutually
205
+ // simultaneous (manualActivation mode only). Without this RNGH treats them
206
+ // as racing gestures and a touch that begins in the handle area could be
207
+ // claimed by whichever recognizer activates first — leading to
208
+ // inconsistent drag start.
209
+ const bodyPanRef = useRef<GestureType | undefined>(undefined);
210
+ const handlePanRef = useRef<GestureType | undefined>(undefined);
136
211
 
137
212
  useKeyboardHandler({
138
213
  onMove: (e) => {
@@ -166,7 +241,19 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
166
241
  renderedRef.current = rendered;
167
242
  }, [rendered]);
168
243
 
169
- const finishClose = useCallback(() => {
244
+ /**
245
+ * Commit a close. Two guards prevent stale callbacks from firing:
246
+ * 1. `hasClosedRef` — protects against the fallback timer AND the
247
+ * animation callback both racing to call us within a single close
248
+ * cycle.
249
+ * 2. `generation` — protects against a callback from a PREVIOUS close
250
+ * cycle firing AFTER the user reopened. If the live generation has
251
+ * advanced past the one captured when the close started, this
252
+ * callback is from a cycle that the user has implicitly cancelled
253
+ * by reopening — silently drop it.
254
+ */
255
+ const finishClose = useCallback((generation: number) => {
256
+ if (closeGenerationRef.current !== generation) return;
170
257
  if (hasClosedRef.current) return;
171
258
  hasClosedRef.current = true;
172
259
  safeClose();
@@ -180,31 +267,41 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
180
267
  closeTimeoutRef.current = null;
181
268
  }
182
269
  hasClosedRef.current = false;
270
+ // Bump generation: any pending close-completion callback from a
271
+ // prior cycle (animation or fallback timer) will now no-op when
272
+ // it eventually fires, because its captured generation is stale.
273
+ closeGenerationRef.current += 1;
274
+ closeGeneration.value = closeGenerationRef.current;
183
275
  opacity.value = withTiming(1, { duration: 250 });
184
276
  translateY.value = withSpring(0, SPRING_CONFIG);
185
277
  } else if (rendered) {
278
+ // Capture the generation for THIS close cycle so the animation
279
+ // callback (running on the UI thread, scheduled back to JS) and
280
+ // the fallback timer agree on which cycle they belong to.
281
+ const generation = closeGenerationRef.current;
186
282
  opacity.value = withTiming(0, { duration: 250 }, (finished) => {
187
283
  if (finished) {
188
- runOnJS(finishClose)();
284
+ runOnJS(finishClose)(generation);
189
285
  }
190
286
  });
191
287
  translateY.value = withSpring(screenHeight, { ...SPRING_CONFIG, stiffness: 250 });
192
288
 
193
- // Fallback timer to ensure close completes (especially on web)
289
+ // Fallback timer to ensure close completes (especially on web
290
+ // where reanimated callbacks occasionally drop on tab blur).
194
291
  if (closeTimeoutRef.current) {
195
292
  clearTimeout(closeTimeoutRef.current);
196
293
  }
197
294
  closeTimeoutRef.current = setTimeout(() => {
198
- finishClose();
295
+ finishClose(generation);
199
296
  closeTimeoutRef.current = null;
200
297
  }, 300);
201
298
  }
202
- }, [visible, rendered, finishClose]);
299
+ }, [visible, rendered, finishClose, screenHeight, closeGeneration, opacity, translateY]);
203
300
 
204
301
  // On unmount: ensure pending close callbacks (e.g. consumer's `onDismiss`)
205
302
  // still fire if the BS is yanked mid-animation by a parent re-render.
206
- // Without this, `Dialog.Outer.handleDismiss` never runs and queued
207
- // callbacks like `Prompt.Action`'s post-close handler are silently lost.
303
+ // Without this, `Dialog`'s `handleDismiss` never runs and queued
304
+ // callbacks (post-close handlers) are silently lost.
208
305
  // Only fires when the sheet was actually rendered (open or closing) to
209
306
  // avoid spuriously calling onDismiss on bare unmount of a never-opened
210
307
  // sheet. Refs are read inside the cleanup, so latest values are captured.
@@ -252,47 +349,61 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
252
349
 
253
350
  const nativeGesture = useMemo(() => Gesture.Native(), []);
254
351
 
255
- // Memoized pan gesture recreating a Gesture.Pan() on every render causes
256
- // gesture detach/reattach during animations and breaks React Compiler memoization.
257
- const panGesture = useMemo(
258
- () =>
259
- Gesture.Pan()
352
+ // Body pan — two strategies, switched by `manualActivation`.
353
+ //
354
+ // (1) Legacy mode (`manualActivation: false`, the bloom 0.3.x default):
355
+ // Pan is always active. We snapshot the scroll offset at `onStart`
356
+ // and gate movement on `scrollOffsetY <= 8` during the gesture.
357
+ // This is what current bloom consumers expect.
358
+ //
359
+ // (2) gorhom-style mode (`manualActivation: true`):
360
+ // Pan uses `manualActivation(true)` and only flips to active when
361
+ // the inner ScrollView is at the top AND the user has moved their
362
+ // finger downward > 8dp. In every other case it fails, so the
363
+ // ScrollView keeps full ownership of the touch. This is the only
364
+ // RNGH 2.x pattern that does not steal vertical events from the
365
+ // inner scroller on Android. Required for FileManagement /
366
+ // PhotoPicker style sheets.
367
+ const panGesture = useMemo(() => {
368
+ if (manualActivation) {
369
+ return Gesture.Pan()
260
370
  .enabled(enablePanDownToClose)
261
- .simultaneousWithExternalGesture(nativeGesture)
262
- .onStart(() => {
371
+ .withRef(bodyPanRef)
372
+ .manualActivation(true)
373
+ .simultaneousWithExternalGesture(scrollViewRef, handlePanRef)
374
+ .onTouchesDown((e) => {
263
375
  'worklet';
376
+ const t = e.changedTouches[0];
377
+ if (t) touchStartY.value = t.absoluteY;
264
378
  context.value = { y: translateY.value };
265
- allowPanClose.value = scrollOffsetY.value <= 8;
266
379
  })
267
- .onUpdate((event) => {
380
+ .onTouchesMove((e, state) => {
268
381
  'worklet';
269
- if (!allowPanClose.value) {
270
- return;
382
+ const t = e.changedTouches[0];
383
+ if (!t) return;
384
+ const dy = t.absoluteY - touchStartY.value;
385
+ const atTop = scrollOffsetY.value <= 4;
386
+ // Activate only when (at scroll top) AND (finger has moved
387
+ // downward by > 8dp). Any other motion: fail so the
388
+ // ScrollView claims the gesture.
389
+ if (atTop && dy > 8) {
390
+ state.activate();
391
+ } else if (dy < -4 || !atTop) {
392
+ state.fail();
271
393
  }
394
+ })
395
+ .onUpdate((event) => {
396
+ 'worklet';
397
+ if (event.translationY < 0) return;
272
398
  const newTranslateY = context.value.y + event.translationY;
273
- // If user is scrolling down while content isn't at (or near) the top, let ScrollView handle it
274
- const atTopOrNearTop = scrollOffsetY.value <= 8; // slightly larger tolerance for smoother handoff
275
- if (event.translationY > 0 && !atTopOrNearTop) {
276
- return;
277
- }
278
399
  if (newTranslateY >= 0) {
279
400
  translateY.value = newTranslateY;
280
- } else if (detached) {
281
- // Only allow overdrag (pulling up beyond top) when detached
282
- translateY.value = newTranslateY * 0.3;
283
- } else {
284
- // In normal mode, prevent overdrag - clamp to 0
285
- translateY.value = 0;
286
401
  }
287
402
  })
288
403
  .onEnd((event) => {
289
404
  'worklet';
290
- if (!allowPanClose.value) {
291
- return;
292
- }
293
405
  const velocity = event.velocityY;
294
406
  const distance = translateY.value;
295
- // Require a deeper pull to close (more like native bottom sheets)
296
407
  const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
297
408
  const fastSwipeThreshold = 900;
298
409
  const shouldClose =
@@ -300,35 +411,156 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
300
411
  (distance > closeThreshold && velocity > -300);
301
412
 
302
413
  if (shouldClose) {
303
- translateY.value = withSpring(screenHeightSV.value, {
304
- ...SPRING_CONFIG,
305
- velocity: velocity,
306
- });
414
+ // Snapshot the generation on the UI thread at the
415
+ // moment the close gesture commits. The completion
416
+ // callback only fires `finishClose` if no reopen
417
+ // bumped the generation in between.
418
+ const generation = closeGeneration.value;
419
+ translateY.value = withSpring(screenHeightSV.value, { ...SPRING_CONFIG, velocity });
307
420
  opacity.value = withTiming(0, { duration: 250 }, (finished) => {
308
- if (finished) {
309
- runOnJS(finishClose)();
310
- }
421
+ if (finished) runOnJS(finishClose)(generation);
311
422
  });
312
423
  } else {
313
- translateY.value = withSpring(0, {
314
- ...SPRING_CONFIG,
315
- velocity: velocity,
316
- });
424
+ translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
317
425
  }
318
- }),
319
- // Shared values are stable refs; enablePanDownToClose and detached are the only
320
- // JS-side values that change the gesture's behavior.
321
- // finishClose is stable (useCallback with stable deps).
426
+ });
427
+ }
428
+
429
+ // Legacy always-active pan (bloom 0.3.x behaviour).
430
+ return Gesture.Pan()
431
+ .enabled(enablePanDownToClose)
432
+ .simultaneousWithExternalGesture(nativeGesture)
433
+ .onStart(() => {
434
+ 'worklet';
435
+ context.value = { y: translateY.value };
436
+ allowPanClose.value = scrollOffsetY.value <= 8;
437
+ })
438
+ .onUpdate((event) => {
439
+ 'worklet';
440
+ if (!allowPanClose.value) {
441
+ return;
442
+ }
443
+ const newTranslateY = context.value.y + event.translationY;
444
+ // If user is scrolling down while content isn't at (or near) the top, let ScrollView handle it
445
+ const atTopOrNearTop = scrollOffsetY.value <= 8; // slightly larger tolerance for smoother handoff
446
+ if (event.translationY > 0 && !atTopOrNearTop) {
447
+ return;
448
+ }
449
+ if (newTranslateY >= 0) {
450
+ translateY.value = newTranslateY;
451
+ } else if (detached) {
452
+ // Only allow overdrag (pulling up beyond top) when detached
453
+ translateY.value = newTranslateY * 0.3;
454
+ } else {
455
+ // In normal mode, prevent overdrag - clamp to 0
456
+ translateY.value = 0;
457
+ }
458
+ })
459
+ .onEnd((event) => {
460
+ 'worklet';
461
+ if (!allowPanClose.value) {
462
+ return;
463
+ }
464
+ const velocity = event.velocityY;
465
+ const distance = translateY.value;
466
+ // Require a deeper pull to close (more like native bottom sheets)
467
+ const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
468
+ const fastSwipeThreshold = 900;
469
+ const shouldClose =
470
+ velocity > fastSwipeThreshold ||
471
+ (distance > closeThreshold && velocity > -300);
472
+
473
+ if (shouldClose) {
474
+ const generation = closeGeneration.value;
475
+ translateY.value = withSpring(screenHeightSV.value, {
476
+ ...SPRING_CONFIG,
477
+ velocity: velocity,
478
+ });
479
+ opacity.value = withTiming(0, { duration: 250 }, (finished) => {
480
+ if (finished) {
481
+ runOnJS(finishClose)(generation);
482
+ }
483
+ });
484
+ } else {
485
+ translateY.value = withSpring(0, {
486
+ ...SPRING_CONFIG,
487
+ velocity: velocity,
488
+ });
489
+ }
490
+ });
491
+ // Shared values are stable refs; the listed deps are the only JS-side
492
+ // values that change the gesture's behavior. `finishClose` is stable
493
+ // (useCallback with stable deps).
322
494
  // eslint-disable-next-line react-hooks/exhaustive-deps
323
- [enablePanDownToClose, detached, nativeGesture, finishClose],
324
- );
495
+ }, [enablePanDownToClose, detached, manualActivation, nativeGesture, finishClose]);
496
+
497
+ // Dedicated handle pan — only built in `manualActivation` mode. Always
498
+ // active so users can drag the handle even while content is mid-scroll.
499
+ // In legacy mode the body pan already wraps the whole sheet (handle
500
+ // included), so no separate gesture is needed.
501
+ const handlePanGesture = useMemo(() => {
502
+ if (!manualActivation) return undefined;
503
+ return Gesture.Pan()
504
+ .enabled(enablePanDownToClose && enableHandlePanningGesture)
505
+ .withRef(handlePanRef)
506
+ .simultaneousWithExternalGesture(bodyPanRef)
507
+ .activeOffsetY([-8, 8])
508
+ .onStart(() => {
509
+ 'worklet';
510
+ context.value = { y: translateY.value };
511
+ })
512
+ .onUpdate((event) => {
513
+ 'worklet';
514
+ const newTranslateY = context.value.y + event.translationY;
515
+ if (newTranslateY >= 0) {
516
+ translateY.value = newTranslateY;
517
+ } else if (detached) {
518
+ translateY.value = newTranslateY * 0.3;
519
+ } else {
520
+ translateY.value = 0;
521
+ }
522
+ })
523
+ .onEnd((event) => {
524
+ 'worklet';
525
+ const velocity = event.velocityY;
526
+ const distance = translateY.value;
527
+ const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
528
+ const fastSwipeThreshold = 900;
529
+ const shouldClose =
530
+ velocity > fastSwipeThreshold ||
531
+ (distance > closeThreshold && velocity > -300);
532
+
533
+ if (shouldClose) {
534
+ const generation = closeGeneration.value;
535
+ translateY.value = withSpring(screenHeightSV.value, { ...SPRING_CONFIG, velocity });
536
+ opacity.value = withTiming(0, { duration: 250 }, (finished) => {
537
+ if (finished) runOnJS(finishClose)(generation);
538
+ });
539
+ } else {
540
+ translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
541
+ }
542
+ });
543
+ // eslint-disable-next-line react-hooks/exhaustive-deps
544
+ }, [manualActivation, enablePanDownToClose, enableHandlePanningGesture, detached, finishClose]);
325
545
 
326
546
  // `opacity.value` drives the fade in/out (0 -> 1). `backdropOpacity` is the
327
547
  // final dim level once fully visible. We multiply so the consumer-provided
328
- // dim opacity applies smoothly across the animation.
329
- const backdropStyle = useAnimatedStyle(() => ({
330
- opacity: opacity.value * backdropOpacity,
331
- }), [backdropOpacity]);
548
+ // dim opacity applies smoothly across the animation. When
549
+ // `dynamicBackdrop` is enabled, the dim also fades proportionally with
550
+ // drag distance (iOS Photos style).
551
+ const backdropStyle = useAnimatedStyle(() => {
552
+ const dragFactor = dynamicBackdrop
553
+ ? interpolate(
554
+ translateY.value,
555
+ [0, screenHeightSV.value * 0.4],
556
+ [1, 0.3],
557
+ 'clamp',
558
+ )
559
+ : 1;
560
+ return {
561
+ opacity: opacity.value * backdropOpacity * dragFactor,
562
+ };
563
+ }, [backdropOpacity, dynamicBackdrop]);
332
564
 
333
565
  const sheetStyle = useAnimatedStyle(() => {
334
566
  const scale = interpolate(translateY.value, [0, screenHeightSV.value], [1, 0.95]);
@@ -392,6 +624,61 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
392
624
 
393
625
  if (!rendered) return null;
394
626
 
627
+ // Default handle render — used when `handleComponent` is not provided.
628
+ const renderDefaultHandle = () => <View style={dynamicStyles.handle} />;
629
+ const handleNode = showHandle ? (handleComponent ? handleComponent() : renderDefaultHandle()) : null;
630
+
631
+ // In `manualActivation` mode the handle gets its own gesture detector
632
+ // sitting in a dedicated absolutely-positioned hit area at the top of the
633
+ // sheet. In legacy mode the handle is rendered inline as a decorative
634
+ // overlay (the body pan covers the entire sheet, handle included).
635
+ const handleSlot = handleNode && manualActivation && handlePanGesture ? (
636
+ <GestureDetector gesture={handlePanGesture}>
637
+ <View style={styles.handleHitArea} accessible accessibilityRole="adjustable">
638
+ {handleNode}
639
+ </View>
640
+ </GestureDetector>
641
+ ) : handleNode;
642
+
643
+ // Inner content: scrollable wraps in Animated.ScrollView, non-scrollable
644
+ // renders children directly. In legacy mode the scrollview is also
645
+ // wrapped in the `nativeGesture` detector for scroll/pan coordination.
646
+ const scrollViewNode = (
647
+ <Animated.ScrollView
648
+ ref={scrollViewRef}
649
+ style={[
650
+ styles.scrollView,
651
+ Platform.OS === 'web' && ({
652
+ scrollbarWidth: 'thin',
653
+ scrollbarColor: `${colors.border} transparent`,
654
+ } as ViewStyle),
655
+ ]}
656
+ contentContainerStyle={dynamicStyles.scrollContent}
657
+ showsVerticalScrollIndicator={false}
658
+ keyboardShouldPersistTaps="handled"
659
+ onScroll={scrollHandler}
660
+ scrollEventThrottle={16}
661
+ {...(Platform.OS === 'web' ? { className: 'bottom-sheet-scrollview' } : undefined)}
662
+ onLayout={() => {
663
+ if (Platform.OS === 'web') {
664
+ createWebScrollbarStyle(colors.border);
665
+ }
666
+ }}
667
+ >
668
+ {children}
669
+ </Animated.ScrollView>
670
+ );
671
+
672
+ const bodyContent = scrollable
673
+ ? (manualActivation
674
+ // In manualActivation mode the scroll view is referenced by the
675
+ // pan's simultaneous list directly; no wrapping native gesture.
676
+ ? scrollViewNode
677
+ // Legacy mode: native gesture wraps the scroll view to coordinate
678
+ // with the always-active body pan.
679
+ : <GestureDetector gesture={nativeGesture}>{scrollViewNode}</GestureDetector>)
680
+ : <View style={styles.nonScrollableContent}>{children}</View>;
681
+
395
682
  return (
396
683
  <Modal visible={rendered} transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
397
684
  {/* RN's Modal renders into its own native window. The app-root
@@ -413,33 +700,9 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
413
700
  <Animated.View style={[dynamicStyles.sheet, sheetMarginStyle, sheetStyle, sheetHeightStyle, style]}>
414
701
  {backgroundComponent?.({ style: styles.background })}
415
702
 
416
- {showHandle && <View style={dynamicStyles.handle} />}
417
-
418
- <GestureDetector gesture={nativeGesture}>
419
- <Animated.ScrollView
420
- ref={scrollViewRef}
421
- style={[
422
- styles.scrollView,
423
- Platform.OS === 'web' && ({
424
- scrollbarWidth: 'thin',
425
- scrollbarColor: `${colors.border} transparent`,
426
- } as ViewStyle),
427
- ]}
428
- contentContainerStyle={dynamicStyles.scrollContent}
429
- showsVerticalScrollIndicator={false}
430
- keyboardShouldPersistTaps="handled"
431
- onScroll={scrollHandler}
432
- scrollEventThrottle={16}
433
- {...(Platform.OS === 'web' ? { className: 'bottom-sheet-scrollview' } : undefined)}
434
- onLayout={() => {
435
- if (Platform.OS === 'web') {
436
- createWebScrollbarStyle(colors.border);
437
- }
438
- }}
439
- >
440
- {children}
441
- </Animated.ScrollView>
442
- </GestureDetector>
703
+ {handleSlot}
704
+
705
+ {bodyContent}
443
706
  </Animated.View>
444
707
  </GestureDetector>
445
708
  </View>
@@ -482,6 +745,7 @@ const styles = StyleSheet.create({
482
745
  borderTopLeftRadius: 24,
483
746
  borderTopRightRadius: 24,
484
747
  },
748
+ /** Legacy (non-manualActivation) handle: decorative overlay only. */
485
749
  handle: {
486
750
  position: 'absolute',
487
751
  top: 10,
@@ -492,6 +756,23 @@ const styles = StyleSheet.create({
492
756
  borderRadius: 3,
493
757
  zIndex: 100,
494
758
  },
759
+ /**
760
+ * Hit area for the drag handle in `manualActivation` mode. Absolutely
761
+ * positioned at the top of the sheet so the area visually "floats" above
762
+ * the content — content scrolls up underneath it (no layout offset)
763
+ * while the thumb can still grab the full-width 28dp strip to drag.
764
+ */
765
+ handleHitArea: {
766
+ position: 'absolute',
767
+ top: 0,
768
+ left: 0,
769
+ right: 0,
770
+ height: 28,
771
+ alignItems: 'center',
772
+ justifyContent: 'flex-start',
773
+ paddingTop: 6,
774
+ zIndex: 100,
775
+ },
495
776
  background: {
496
777
  ...StyleSheet.absoluteFillObject,
497
778
  },
@@ -501,6 +782,9 @@ const styles = StyleSheet.create({
501
782
  scrollContent: {
502
783
  flexGrow: 1,
503
784
  },
785
+ nonScrollableContent: {
786
+ flex: 1,
787
+ },
504
788
  });
505
789
 
506
790
  // Create web scrollbar styles dynamically based on theme
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * ContextMenu — Native implementation
3
3
  *
4
- * Opens a bottom-sheet menu via Bloom's Dialog when the user long-presses
5
- * the trigger. The menu body is rendered through Bloom's Menu component
6
- * pattern using Dialog.Outer / Dialog.ScrollableInner.
4
+ * Opens a bottom-sheet menu when the user long-presses the trigger. The
5
+ * menu body uses bloom's internal `SheetShell` (a `BottomSheet`
6
+ * presentation primitive with the same drag-handle + close-on-tap
7
+ * semantics shared by `Menu` and `Select`).
7
8
  */
8
9
  import React, {
9
10
  createContext,
@@ -15,7 +16,9 @@ import { Pressable, StyleSheet, View } from 'react-native';
15
16
 
16
17
  import { useTheme } from '../theme/use-theme';
17
18
  import { Text } from '../typography';
18
- import * as Dialog from '../dialog';
19
+ import { useDialogControl } from '../dialog/context';
20
+ import { SheetShell } from '../dialog/SheetShell';
21
+ import type { DialogControlProps } from '../dialog/types';
19
22
  import { useInteractionState } from '../hooks/useInteractionState';
20
23
  import { ItemCtx, useItemContext } from './context';
21
24
  import type {
@@ -34,7 +37,7 @@ import type {
34
37
  // ---------------------------------------------------------------------------
35
38
 
36
39
  type NativeContextMenuContextValue = ContextMenuContextValue & {
37
- control: Dialog.DialogControlProps;
40
+ control: DialogControlProps;
38
41
  };
39
42
 
40
43
  const NativeContextMenuContext = createContext<NativeContextMenuContextValue | null>(null);
@@ -55,7 +58,7 @@ function useNativeContextMenuContext(): NativeContextMenuContextValue {
55
58
  // ---------------------------------------------------------------------------
56
59
 
57
60
  export function Root({ children }: { children: React.ReactNode }) {
58
- const control = Dialog.useDialogControl();
61
+ const control = useDialogControl();
59
62
 
60
63
  const ctx = useMemo(
61
64
  () => ({
@@ -112,8 +115,7 @@ export function Outer({ children, style }: OuterProps) {
112
115
  const { control } = useNativeContextMenuContext();
113
116
 
114
117
  return (
115
- <Dialog.Outer control={control} preventExpansion>
116
- <Dialog.Handle />
118
+ <SheetShell control={control} label="Context menu">
117
119
  <NativeContextMenuContext.Provider
118
120
  value={{
119
121
  isOpen: true,
@@ -122,11 +124,9 @@ export function Outer({ children, style }: OuterProps) {
122
124
  control,
123
125
  }}
124
126
  >
125
- <Dialog.ScrollableInner label="Context menu">
126
- <View style={[styles.outerContent, style]}>{children}</View>
127
- </Dialog.ScrollableInner>
127
+ <View style={[styles.outerContent, style]}>{children}</View>
128
128
  </NativeContextMenuContext.Provider>
129
- </Dialog.Outer>
129
+ </SheetShell>
130
130
  );
131
131
  }
132
132