@oxyhq/bloom 0.3.11 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -222,9 +222,37 @@ function Example() {
222
222
  - `enableHandlePanningGesture?: boolean` — defaults to `true`.
223
223
  - `onDismissAttempt?: () => boolean` — return `false` to veto a dismiss attempt.
224
224
  - `detached?: boolean` — when `true`, the sheet floats with horizontal margins and rounded corners on all sides; when `false`, it's flush to the bottom edges with rounded top corners only.
225
+ - `showHandle?: boolean` — defaults to `true`. Toggles the drag handle pill at the top of the sheet.
226
+ - `backdropOpacity?: number` — opacity (0–1) of the dimming backdrop once fully visible. Defaults to `0.5`. Use a higher value (e.g. `0.7`) when stacking a sheet over another sheet.
225
227
  - `backgroundComponent?` — custom background renderer.
226
228
  - `backdropComponent?` — custom backdrop renderer.
227
229
  - `style?`
230
+ - `scrollable?: boolean` — defaults to `true`. When `false`, renders `children` directly without the internal `Animated.ScrollView` wrapper. **Required when the sheet's content owns its own scrolling primitive** (e.g. `FlatList`, `SectionList`, or any `VirtualizedList`) — nesting a virtualized list inside the internal ScrollView breaks windowing and triggers a React Native warning. Combine with `manualActivation` so the handle stays draggable while the inner list owns the scroll.
231
+ - `manualActivation?: boolean` — defaults to `false`. When `true`, the body pan uses RNGH's `manualActivation` and only activates when (a) the inner ScrollView is at the top AND (b) the user has moved their finger downward by > 8dp. This is the `@gorhom/bottom-sheet` coordination model — recommended for sheets that contain a scrolling region on Android (the legacy always-active pan can steal vertical events from the inner scroller). Enabling this also gives the drag handle its own dedicated, unconditionally-active gesture so users can always grab the handle even mid-scroll.
232
+ - `dynamicBackdrop?: boolean` — defaults to `false`. When `true`, the backdrop dims proportionally to drag distance — fades from full `backdropOpacity` (sheet at rest) to 30% as the sheet is pulled down 40% of the screen height. This is the iOS Photos / iMessage drag-to-dismiss look. The base `backdropOpacity` still controls the resting dim.
233
+ - `handleComponent?: () => React.ReactNode` — custom drag-handle renderer. When provided (and `showHandle` is `true`), replaces the default 36×5 pill. In `manualActivation` mode the rendered handle sits inside the dedicated handle hit-area and gesture detector so it remains unconditionally draggable.
234
+
235
+ **Pattern: sheet with a `FlatList` inside.** Use `scrollable={false}` so the BottomSheet doesn't wrap the list in its own ScrollView, plus `manualActivation` so the drag handle remains the dedicated drag-to-dismiss surface while the list owns vertical scroll:
236
+
237
+ ```tsx
238
+ <BottomSheet ref={sheetRef} scrollable={false} manualActivation>
239
+ <FlatList data={items} renderItem={renderItem} />
240
+ </BottomSheet>
241
+ ```
242
+
243
+ **Pattern: iOS Photos-style backdrop dim.** Combine `manualActivation` (so the inner photo grid keeps scroll ownership) with `dynamicBackdrop` (so the overlay fades as the user pulls down):
244
+
245
+ ```tsx
246
+ <BottomSheet
247
+ ref={sheetRef}
248
+ scrollable={false}
249
+ manualActivation
250
+ dynamicBackdrop
251
+ backdropOpacity={0.85}
252
+ >
253
+ <PhotoGrid />
254
+ </BottomSheet>
255
+ ```
228
256
 
229
257
  ### Button
230
258
 
@@ -54,7 +54,11 @@ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((p
54
54
  onDismissAttempt,
55
55
  detached = false,
56
56
  showHandle = true,
57
- backdropOpacity = 0.5
57
+ backdropOpacity = 0.5,
58
+ scrollable = true,
59
+ manualActivation = false,
60
+ dynamicBackdrop = false,
61
+ handleComponent
58
62
  } = props;
59
63
  const insets = (0, _reactNativeSafeAreaContext.useSafeAreaInsets)();
60
64
  const theme = (0, _useTheme.useTheme)();
@@ -69,6 +73,16 @@ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((p
69
73
  const closeTimeoutRef = (0, _react.useRef)(null);
70
74
  const hasClosedRef = (0, _react.useRef)(false);
71
75
  const scrollViewRef = (0, _react.useRef)(null);
76
+ /**
77
+ * Monotonically increasing counter that identifies "the current close
78
+ * attempt". Bumped every time the sheet re-opens (`present()`), so any
79
+ * in-flight `withTiming` completion callback or fallback timer from a
80
+ * PREVIOUS close cycle becomes a no-op. Without this guard, a stale
81
+ * `runOnJS(finishClose)` from an aborted close would fire `onDismiss`
82
+ * and unmount the sheet immediately after the user opens it again,
83
+ * causing "tap to open does nothing" reports in production.
84
+ */
85
+ const closeGenerationRef = (0, _react.useRef)(0);
72
86
  const screenHeightSV = (0, _reactNativeReanimated.useSharedValue)(screenHeight);
73
87
  // Keep shared value in sync when screen dimensions change (rotation/resize)
74
88
  (0, _react.useEffect)(() => {
@@ -83,6 +97,21 @@ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((p
83
97
  const context = (0, _reactNativeReanimated.useSharedValue)({
84
98
  y: 0
85
99
  });
100
+ // Mirror of `closeGenerationRef` for worklet access. Bumped from the JS
101
+ // thread in lockstep with the ref so gesture worklets always see the
102
+ // current generation when they snapshot it on `onEnd`.
103
+ const closeGeneration = (0, _reactNativeReanimated.useSharedValue)(0);
104
+ // Used by `manualActivation` body pan to track the touch's initial Y so
105
+ // it can compute downward distance for the activation decision.
106
+ const touchStartY = (0, _reactNativeReanimated.useSharedValue)(0);
107
+
108
+ // Refs used to mark the handle pan and the body pan as mutually
109
+ // simultaneous (manualActivation mode only). Without this RNGH treats them
110
+ // as racing gestures and a touch that begins in the handle area could be
111
+ // claimed by whichever recognizer activates first — leading to
112
+ // inconsistent drag start.
113
+ const bodyPanRef = (0, _react.useRef)(undefined);
114
+ const handlePanRef = (0, _react.useRef)(undefined);
86
115
  useKeyboardHandler({
87
116
  onMove: e => {
88
117
  'worklet';
@@ -116,7 +145,20 @@ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((p
116
145
  (0, _react.useEffect)(() => {
117
146
  renderedRef.current = rendered;
118
147
  }, [rendered]);
119
- const finishClose = (0, _react.useCallback)(() => {
148
+
149
+ /**
150
+ * Commit a close. Two guards prevent stale callbacks from firing:
151
+ * 1. `hasClosedRef` — protects against the fallback timer AND the
152
+ * animation callback both racing to call us within a single close
153
+ * cycle.
154
+ * 2. `generation` — protects against a callback from a PREVIOUS close
155
+ * cycle firing AFTER the user reopened. If the live generation has
156
+ * advanced past the one captured when the close started, this
157
+ * callback is from a cycle that the user has implicitly cancelled
158
+ * by reopening — silently drop it.
159
+ */
160
+ const finishClose = (0, _react.useCallback)(generation => {
161
+ if (closeGenerationRef.current !== generation) return;
120
162
  if (hasClosedRef.current) return;
121
163
  hasClosedRef.current = true;
122
164
  safeClose();
@@ -129,16 +171,25 @@ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((p
129
171
  closeTimeoutRef.current = null;
130
172
  }
131
173
  hasClosedRef.current = false;
174
+ // Bump generation: any pending close-completion callback from a
175
+ // prior cycle (animation or fallback timer) will now no-op when
176
+ // it eventually fires, because its captured generation is stale.
177
+ closeGenerationRef.current += 1;
178
+ closeGeneration.value = closeGenerationRef.current;
132
179
  opacity.value = (0, _reactNativeReanimated.withTiming)(1, {
133
180
  duration: 250
134
181
  });
135
182
  translateY.value = (0, _reactNativeReanimated.withSpring)(0, SPRING_CONFIG);
136
183
  } else if (rendered) {
184
+ // Capture the generation for THIS close cycle so the animation
185
+ // callback (running on the UI thread, scheduled back to JS) and
186
+ // the fallback timer agree on which cycle they belong to.
187
+ const generation = closeGenerationRef.current;
137
188
  opacity.value = (0, _reactNativeReanimated.withTiming)(0, {
138
189
  duration: 250
139
190
  }, finished => {
140
191
  if (finished) {
141
- (0, _reactNativeReanimated.runOnJS)(finishClose)();
192
+ (0, _reactNativeReanimated.runOnJS)(finishClose)(generation);
142
193
  }
143
194
  });
144
195
  translateY.value = (0, _reactNativeReanimated.withSpring)(screenHeight, {
@@ -146,16 +197,17 @@ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((p
146
197
  stiffness: 250
147
198
  });
148
199
 
149
- // Fallback timer to ensure close completes (especially on web)
200
+ // Fallback timer to ensure close completes (especially on web
201
+ // where reanimated callbacks occasionally drop on tab blur).
150
202
  if (closeTimeoutRef.current) {
151
203
  clearTimeout(closeTimeoutRef.current);
152
204
  }
153
205
  closeTimeoutRef.current = setTimeout(() => {
154
- finishClose();
206
+ finishClose(generation);
155
207
  closeTimeoutRef.current = null;
156
208
  }, 300);
157
209
  }
158
- }, [visible, rendered, finishClose]);
210
+ }, [visible, rendered, finishClose, screenHeight, closeGeneration, opacity, translateY]);
159
211
 
160
212
  // On unmount: ensure pending close callbacks (e.g. consumer's `onDismiss`)
161
213
  // still fire if the BS is yanked mid-animation by a parent re-render.
@@ -207,79 +259,216 @@ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((p
207
259
  }), [present, dismiss, scrollTo]);
208
260
  const nativeGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Native(), []);
209
261
 
210
- // Memoized pan gesture recreating a Gesture.Pan() on every render causes
211
- // gesture detach/reattach during animations and breaks React Compiler memoization.
212
- const panGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Pan().enabled(enablePanDownToClose).simultaneousWithExternalGesture(nativeGesture).onStart(() => {
213
- 'worklet';
262
+ // Body pan — two strategies, switched by `manualActivation`.
263
+ //
264
+ // (1) Legacy mode (`manualActivation: false`, the bloom 0.3.x default):
265
+ // Pan is always active. We snapshot the scroll offset at `onStart`
266
+ // and gate movement on `scrollOffsetY <= 8` during the gesture.
267
+ // This is what current bloom consumers expect.
268
+ //
269
+ // (2) gorhom-style mode (`manualActivation: true`):
270
+ // Pan uses `manualActivation(true)` and only flips to active when
271
+ // the inner ScrollView is at the top AND the user has moved their
272
+ // finger downward > 8dp. In every other case it fails, so the
273
+ // ScrollView keeps full ownership of the touch. This is the only
274
+ // RNGH 2.x pattern that does not steal vertical events from the
275
+ // inner scroller on Android. Required for FileManagement /
276
+ // PhotoPicker style sheets.
277
+ const panGesture = (0, _react.useMemo)(() => {
278
+ if (manualActivation) {
279
+ return _reactNativeGestureHandler.Gesture.Pan().enabled(enablePanDownToClose).withRef(bodyPanRef).manualActivation(true).simultaneousWithExternalGesture(scrollViewRef, handlePanRef).onTouchesDown(e => {
280
+ 'worklet';
214
281
 
215
- context.value = {
216
- y: translateY.value
217
- };
218
- allowPanClose.value = scrollOffsetY.value <= 8;
219
- }).onUpdate(event => {
220
- 'worklet';
282
+ const t = e.changedTouches[0];
283
+ if (t) touchStartY.value = t.absoluteY;
284
+ context.value = {
285
+ y: translateY.value
286
+ };
287
+ }).onTouchesMove((e, state) => {
288
+ 'worklet';
221
289
 
222
- if (!allowPanClose.value) {
223
- return;
224
- }
225
- const newTranslateY = context.value.y + event.translationY;
226
- // If user is scrolling down while content isn't at (or near) the top, let ScrollView handle it
227
- const atTopOrNearTop = scrollOffsetY.value <= 8; // slightly larger tolerance for smoother handoff
228
- if (event.translationY > 0 && !atTopOrNearTop) {
229
- return;
230
- }
231
- if (newTranslateY >= 0) {
232
- translateY.value = newTranslateY;
233
- } else if (detached) {
234
- // Only allow overdrag (pulling up beyond top) when detached
235
- translateY.value = newTranslateY * 0.3;
236
- } else {
237
- // In normal mode, prevent overdrag - clamp to 0
238
- translateY.value = 0;
239
- }
240
- }).onEnd(event => {
241
- 'worklet';
290
+ const t = e.changedTouches[0];
291
+ if (!t) return;
292
+ const dy = t.absoluteY - touchStartY.value;
293
+ const atTop = scrollOffsetY.value <= 4;
294
+ // Activate only when (at scroll top) AND (finger has moved
295
+ // downward by > 8dp). Any other motion: fail so the
296
+ // ScrollView claims the gesture.
297
+ if (atTop && dy > 8) {
298
+ state.activate();
299
+ } else if (dy < -4 || !atTop) {
300
+ state.fail();
301
+ }
302
+ }).onUpdate(event => {
303
+ 'worklet';
242
304
 
243
- if (!allowPanClose.value) {
244
- return;
245
- }
246
- const velocity = event.velocityY;
247
- const distance = translateY.value;
248
- // Require a deeper pull to close (more like native bottom sheets)
249
- const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
250
- const fastSwipeThreshold = 900;
251
- const shouldClose = velocity > fastSwipeThreshold || distance > closeThreshold && velocity > -300;
252
- if (shouldClose) {
253
- translateY.value = (0, _reactNativeReanimated.withSpring)(screenHeightSV.value, {
254
- ...SPRING_CONFIG,
255
- velocity: velocity
256
- });
257
- opacity.value = (0, _reactNativeReanimated.withTiming)(0, {
258
- duration: 250
259
- }, finished => {
260
- if (finished) {
261
- (0, _reactNativeReanimated.runOnJS)(finishClose)();
305
+ if (event.translationY < 0) return;
306
+ const newTranslateY = context.value.y + event.translationY;
307
+ if (newTranslateY >= 0) {
308
+ translateY.value = newTranslateY;
309
+ }
310
+ }).onEnd(event => {
311
+ 'worklet';
312
+
313
+ const velocity = event.velocityY;
314
+ const distance = translateY.value;
315
+ const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
316
+ const fastSwipeThreshold = 900;
317
+ const shouldClose = velocity > fastSwipeThreshold || distance > closeThreshold && velocity > -300;
318
+ if (shouldClose) {
319
+ // Snapshot the generation on the UI thread at the
320
+ // moment the close gesture commits. The completion
321
+ // callback only fires `finishClose` if no reopen
322
+ // bumped the generation in between.
323
+ const generation = closeGeneration.value;
324
+ translateY.value = (0, _reactNativeReanimated.withSpring)(screenHeightSV.value, {
325
+ ...SPRING_CONFIG,
326
+ velocity
327
+ });
328
+ opacity.value = (0, _reactNativeReanimated.withTiming)(0, {
329
+ duration: 250
330
+ }, finished => {
331
+ if (finished) (0, _reactNativeReanimated.runOnJS)(finishClose)(generation);
332
+ });
333
+ } else {
334
+ translateY.value = (0, _reactNativeReanimated.withSpring)(0, {
335
+ ...SPRING_CONFIG,
336
+ velocity
337
+ });
262
338
  }
263
- });
264
- } else {
265
- translateY.value = (0, _reactNativeReanimated.withSpring)(0, {
266
- ...SPRING_CONFIG,
267
- velocity: velocity
268
339
  });
269
340
  }
270
- }),
271
- // Shared values are stable refs; enablePanDownToClose and detached are the only
272
- // JS-side values that change the gesture's behavior.
273
- // finishClose is stable (useCallback with stable deps).
274
- // eslint-disable-next-line react-hooks/exhaustive-deps
275
- [enablePanDownToClose, detached, nativeGesture, finishClose]);
341
+
342
+ // Legacy always-active pan (bloom 0.3.x behaviour).
343
+ return _reactNativeGestureHandler.Gesture.Pan().enabled(enablePanDownToClose).simultaneousWithExternalGesture(nativeGesture).onStart(() => {
344
+ 'worklet';
345
+
346
+ context.value = {
347
+ y: translateY.value
348
+ };
349
+ allowPanClose.value = scrollOffsetY.value <= 8;
350
+ }).onUpdate(event => {
351
+ 'worklet';
352
+
353
+ if (!allowPanClose.value) {
354
+ return;
355
+ }
356
+ const newTranslateY = context.value.y + event.translationY;
357
+ // If user is scrolling down while content isn't at (or near) the top, let ScrollView handle it
358
+ const atTopOrNearTop = scrollOffsetY.value <= 8; // slightly larger tolerance for smoother handoff
359
+ if (event.translationY > 0 && !atTopOrNearTop) {
360
+ return;
361
+ }
362
+ if (newTranslateY >= 0) {
363
+ translateY.value = newTranslateY;
364
+ } else if (detached) {
365
+ // Only allow overdrag (pulling up beyond top) when detached
366
+ translateY.value = newTranslateY * 0.3;
367
+ } else {
368
+ // In normal mode, prevent overdrag - clamp to 0
369
+ translateY.value = 0;
370
+ }
371
+ }).onEnd(event => {
372
+ 'worklet';
373
+
374
+ if (!allowPanClose.value) {
375
+ return;
376
+ }
377
+ const velocity = event.velocityY;
378
+ const distance = translateY.value;
379
+ // Require a deeper pull to close (more like native bottom sheets)
380
+ const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
381
+ const fastSwipeThreshold = 900;
382
+ const shouldClose = velocity > fastSwipeThreshold || distance > closeThreshold && velocity > -300;
383
+ if (shouldClose) {
384
+ const generation = closeGeneration.value;
385
+ translateY.value = (0, _reactNativeReanimated.withSpring)(screenHeightSV.value, {
386
+ ...SPRING_CONFIG,
387
+ velocity: velocity
388
+ });
389
+ opacity.value = (0, _reactNativeReanimated.withTiming)(0, {
390
+ duration: 250
391
+ }, finished => {
392
+ if (finished) {
393
+ (0, _reactNativeReanimated.runOnJS)(finishClose)(generation);
394
+ }
395
+ });
396
+ } else {
397
+ translateY.value = (0, _reactNativeReanimated.withSpring)(0, {
398
+ ...SPRING_CONFIG,
399
+ velocity: velocity
400
+ });
401
+ }
402
+ });
403
+ // Shared values are stable refs; the listed deps are the only JS-side
404
+ // values that change the gesture's behavior. `finishClose` is stable
405
+ // (useCallback with stable deps).
406
+ // eslint-disable-next-line react-hooks/exhaustive-deps
407
+ }, [enablePanDownToClose, detached, manualActivation, nativeGesture, finishClose]);
408
+
409
+ // Dedicated handle pan — only built in `manualActivation` mode. Always
410
+ // active so users can drag the handle even while content is mid-scroll.
411
+ // In legacy mode the body pan already wraps the whole sheet (handle
412
+ // included), so no separate gesture is needed.
413
+ const handlePanGesture = (0, _react.useMemo)(() => {
414
+ if (!manualActivation) return undefined;
415
+ return _reactNativeGestureHandler.Gesture.Pan().enabled(enablePanDownToClose && enableHandlePanningGesture).withRef(handlePanRef).simultaneousWithExternalGesture(bodyPanRef).activeOffsetY([-8, 8]).onStart(() => {
416
+ 'worklet';
417
+
418
+ context.value = {
419
+ y: translateY.value
420
+ };
421
+ }).onUpdate(event => {
422
+ 'worklet';
423
+
424
+ const newTranslateY = context.value.y + event.translationY;
425
+ if (newTranslateY >= 0) {
426
+ translateY.value = newTranslateY;
427
+ } else if (detached) {
428
+ translateY.value = newTranslateY * 0.3;
429
+ } else {
430
+ translateY.value = 0;
431
+ }
432
+ }).onEnd(event => {
433
+ 'worklet';
434
+
435
+ const velocity = event.velocityY;
436
+ const distance = translateY.value;
437
+ const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
438
+ const fastSwipeThreshold = 900;
439
+ const shouldClose = velocity > fastSwipeThreshold || distance > closeThreshold && velocity > -300;
440
+ if (shouldClose) {
441
+ const generation = closeGeneration.value;
442
+ translateY.value = (0, _reactNativeReanimated.withSpring)(screenHeightSV.value, {
443
+ ...SPRING_CONFIG,
444
+ velocity
445
+ });
446
+ opacity.value = (0, _reactNativeReanimated.withTiming)(0, {
447
+ duration: 250
448
+ }, finished => {
449
+ if (finished) (0, _reactNativeReanimated.runOnJS)(finishClose)(generation);
450
+ });
451
+ } else {
452
+ translateY.value = (0, _reactNativeReanimated.withSpring)(0, {
453
+ ...SPRING_CONFIG,
454
+ velocity
455
+ });
456
+ }
457
+ });
458
+ // eslint-disable-next-line react-hooks/exhaustive-deps
459
+ }, [manualActivation, enablePanDownToClose, enableHandlePanningGesture, detached, finishClose]);
276
460
 
277
461
  // `opacity.value` drives the fade in/out (0 -> 1). `backdropOpacity` is the
278
462
  // final dim level once fully visible. We multiply so the consumer-provided
279
- // dim opacity applies smoothly across the animation.
280
- const backdropStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({
281
- opacity: opacity.value * backdropOpacity
282
- }), [backdropOpacity]);
463
+ // dim opacity applies smoothly across the animation. When
464
+ // `dynamicBackdrop` is enabled, the dim also fades proportionally with
465
+ // drag distance (iOS Photos style).
466
+ const backdropStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => {
467
+ const dragFactor = dynamicBackdrop ? (0, _reactNativeReanimated.interpolate)(translateY.value, [0, screenHeightSV.value * 0.4], [1, 0.3], 'clamp') : 1;
468
+ return {
469
+ opacity: opacity.value * backdropOpacity * dragFactor
470
+ };
471
+ }, [backdropOpacity, dynamicBackdrop]);
283
472
  const sheetStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => {
284
473
  const scale = (0, _reactNativeReanimated.interpolate)(translateY.value, [0, screenHeightSV.value], [1, 0.95]);
285
474
  return {
@@ -336,6 +525,64 @@ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((p
336
525
  });
337
526
  }, [colors.background, theme.isDark, detached]);
338
527
  if (!rendered) return null;
528
+
529
+ // Default handle render — used when `handleComponent` is not provided.
530
+ const renderDefaultHandle = () => /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
531
+ style: dynamicStyles.handle
532
+ });
533
+ const handleNode = showHandle ? handleComponent ? handleComponent() : renderDefaultHandle() : null;
534
+
535
+ // In `manualActivation` mode the handle gets its own gesture detector
536
+ // sitting in a dedicated absolutely-positioned hit area at the top of the
537
+ // sheet. In legacy mode the handle is rendered inline as a decorative
538
+ // overlay (the body pan covers the entire sheet, handle included).
539
+ const handleSlot = handleNode && manualActivation && handlePanGesture ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, {
540
+ gesture: handlePanGesture,
541
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
542
+ style: styles.handleHitArea,
543
+ accessible: true,
544
+ accessibilityRole: "adjustable",
545
+ children: handleNode
546
+ })
547
+ }) : handleNode;
548
+
549
+ // Inner content: scrollable wraps in Animated.ScrollView, non-scrollable
550
+ // renders children directly. In legacy mode the scrollview is also
551
+ // wrapped in the `nativeGesture` detector for scroll/pan coordination.
552
+ const scrollViewNode = /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.ScrollView, {
553
+ ref: scrollViewRef,
554
+ style: [styles.scrollView, _reactNative.Platform.OS === 'web' && {
555
+ scrollbarWidth: 'thin',
556
+ scrollbarColor: `${colors.border} transparent`
557
+ }],
558
+ contentContainerStyle: dynamicStyles.scrollContent,
559
+ showsVerticalScrollIndicator: false,
560
+ keyboardShouldPersistTaps: "handled",
561
+ onScroll: scrollHandler,
562
+ scrollEventThrottle: 16,
563
+ ...(_reactNative.Platform.OS === 'web' ? {
564
+ className: 'bottom-sheet-scrollview'
565
+ } : undefined),
566
+ onLayout: () => {
567
+ if (_reactNative.Platform.OS === 'web') {
568
+ createWebScrollbarStyle(colors.border);
569
+ }
570
+ },
571
+ children: children
572
+ });
573
+ const bodyContent = scrollable ? manualActivation
574
+ // In manualActivation mode the scroll view is referenced by the
575
+ // pan's simultaneous list directly; no wrapping native gesture.
576
+ ? scrollViewNode
577
+ // Legacy mode: native gesture wraps the scroll view to coordinate
578
+ // with the always-active body pan.
579
+ : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, {
580
+ gesture: nativeGesture,
581
+ children: scrollViewNode
582
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
583
+ style: styles.nonScrollableContent,
584
+ children: children
585
+ });
339
586
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Modal, {
340
587
  visible: rendered,
341
588
  transparent: true,
@@ -363,32 +610,7 @@ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((p
363
610
  style: [dynamicStyles.sheet, sheetMarginStyle, sheetStyle, sheetHeightStyle, style],
364
611
  children: [backgroundComponent?.({
365
612
  style: styles.background
366
- }), showHandle && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
367
- style: dynamicStyles.handle
368
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, {
369
- gesture: nativeGesture,
370
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.ScrollView, {
371
- ref: scrollViewRef,
372
- style: [styles.scrollView, _reactNative.Platform.OS === 'web' && {
373
- scrollbarWidth: 'thin',
374
- scrollbarColor: `${colors.border} transparent`
375
- }],
376
- contentContainerStyle: dynamicStyles.scrollContent,
377
- showsVerticalScrollIndicator: false,
378
- keyboardShouldPersistTaps: "handled",
379
- onScroll: scrollHandler,
380
- scrollEventThrottle: 16,
381
- ...(_reactNative.Platform.OS === 'web' ? {
382
- className: 'bottom-sheet-scrollview'
383
- } : undefined),
384
- onLayout: () => {
385
- if (_reactNative.Platform.OS === 'web') {
386
- createWebScrollbarStyle(colors.border);
387
- }
388
- },
389
- children: children
390
- })
391
- })]
613
+ }), handleSlot, bodyContent]
392
614
  })
393
615
  })]
394
616
  })
@@ -428,6 +650,7 @@ const styles = _reactNative.StyleSheet.create({
428
650
  borderTopLeftRadius: 24,
429
651
  borderTopRightRadius: 24
430
652
  },
653
+ /** Legacy (non-manualActivation) handle: decorative overlay only. */
431
654
  handle: {
432
655
  position: 'absolute',
433
656
  top: 10,
@@ -438,6 +661,23 @@ const styles = _reactNative.StyleSheet.create({
438
661
  borderRadius: 3,
439
662
  zIndex: 100
440
663
  },
664
+ /**
665
+ * Hit area for the drag handle in `manualActivation` mode. Absolutely
666
+ * positioned at the top of the sheet so the area visually "floats" above
667
+ * the content — content scrolls up underneath it (no layout offset)
668
+ * while the thumb can still grab the full-width 28dp strip to drag.
669
+ */
670
+ handleHitArea: {
671
+ position: 'absolute',
672
+ top: 0,
673
+ left: 0,
674
+ right: 0,
675
+ height: 28,
676
+ alignItems: 'center',
677
+ justifyContent: 'flex-start',
678
+ paddingTop: 6,
679
+ zIndex: 100
680
+ },
441
681
  background: {
442
682
  ..._reactNative.StyleSheet.absoluteFillObject
443
683
  },
@@ -446,6 +686,9 @@ const styles = _reactNative.StyleSheet.create({
446
686
  },
447
687
  scrollContent: {
448
688
  flexGrow: 1
689
+ },
690
+ nonScrollableContent: {
691
+ flex: 1
449
692
  }
450
693
  });
451
694