@oxyhq/bloom 0.30.4 → 0.30.6

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 (66) hide show
  1. package/lib/commonjs/avatar/Avatar.js +4 -2
  2. package/lib/commonjs/avatar/Avatar.js.map +1 -1
  3. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +744 -0
  4. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -0
  5. package/lib/commonjs/bottom-sheet/index.js +24 -694
  6. package/lib/commonjs/bottom-sheet/index.js.map +1 -1
  7. package/lib/commonjs/bottom-sheet/index.web.js +64 -0
  8. package/lib/commonjs/bottom-sheet/index.web.js.map +1 -0
  9. package/lib/commonjs/dialog/Dialog.js +2 -2
  10. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  11. package/lib/commonjs/dialog/DialogBottomSheet.js +2 -2
  12. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  13. package/lib/commonjs/dialog/SheetShell.js +2 -2
  14. package/lib/commonjs/dialog/SheetShell.js.map +1 -1
  15. package/lib/commonjs/index.js +44 -44
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/index.web.js +66 -66
  18. package/lib/commonjs/index.web.js.map +1 -1
  19. package/lib/module/avatar/Avatar.js +4 -2
  20. package/lib/module/avatar/Avatar.js.map +1 -1
  21. package/lib/module/bottom-sheet/BottomSheetBase.js +741 -0
  22. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -0
  23. package/lib/module/bottom-sheet/index.js +27 -696
  24. package/lib/module/bottom-sheet/index.js.map +1 -1
  25. package/lib/module/bottom-sheet/index.web.js +63 -0
  26. package/lib/module/bottom-sheet/index.web.js.map +1 -0
  27. package/lib/module/dialog/Dialog.js +1 -1
  28. package/lib/module/dialog/Dialog.js.map +1 -1
  29. package/lib/module/dialog/DialogBottomSheet.js +1 -1
  30. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  31. package/lib/module/dialog/SheetShell.js +1 -1
  32. package/lib/module/dialog/SheetShell.js.map +1 -1
  33. package/lib/module/index.js +1 -1
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/index.web.js +1 -1
  36. package/lib/module/index.web.js.map +1 -1
  37. package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/avatar/types.d.ts +4 -3
  39. package/lib/typescript/commonjs/avatar/types.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts +127 -0
  41. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -0
  42. package/lib/typescript/commonjs/bottom-sheet/index.d.ts +2 -90
  43. package/lib/typescript/commonjs/bottom-sheet/index.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/bottom-sheet/index.web.d.ts +7 -0
  45. package/lib/typescript/commonjs/bottom-sheet/index.web.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/index.web.d.ts +2 -2
  47. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  48. package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
  49. package/lib/typescript/module/avatar/types.d.ts +4 -3
  50. package/lib/typescript/module/avatar/types.d.ts.map +1 -1
  51. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts +127 -0
  52. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -0
  53. package/lib/typescript/module/bottom-sheet/index.d.ts +2 -90
  54. package/lib/typescript/module/bottom-sheet/index.d.ts.map +1 -1
  55. package/lib/typescript/module/bottom-sheet/index.web.d.ts +7 -0
  56. package/lib/typescript/module/bottom-sheet/index.web.d.ts.map +1 -0
  57. package/lib/typescript/module/index.web.d.ts +2 -2
  58. package/lib/typescript/module/index.web.d.ts.map +1 -1
  59. package/package.json +6 -1
  60. package/src/__tests__/BottomSheetWebFork.test.ts +66 -0
  61. package/src/avatar/Avatar.tsx +4 -2
  62. package/src/avatar/types.ts +4 -3
  63. package/src/bottom-sheet/BottomSheetBase.tsx +866 -0
  64. package/src/bottom-sheet/index.tsx +26 -821
  65. package/src/bottom-sheet/index.web.tsx +66 -0
  66. package/src/index.web.ts +2 -2
@@ -0,0 +1,866 @@
1
+ import type React from 'react';
2
+ import { forwardRef, useImperativeHandle, useRef, useEffect, useState, useCallback, useMemo } from 'react';
3
+ import {
4
+ View,
5
+ StyleSheet,
6
+ Pressable,
7
+ Dimensions,
8
+ Platform,
9
+ type ViewStyle,
10
+ type StyleProp,
11
+ } from 'react-native';
12
+ import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
13
+ import { Z_INDEX } from '../styles/z-index';
14
+ import Animated, {
15
+ interpolate,
16
+ runOnJS,
17
+ type SharedValue,
18
+ useAnimatedScrollHandler,
19
+ useAnimatedStyle,
20
+ useSharedValue,
21
+ withSpring,
22
+ withTiming,
23
+ } from 'react-native-reanimated';
24
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
25
+ import { useTheme } from '../theme/use-theme';
26
+
27
+ /** Hook that returns current screen dimensions and updates on rotation/resize. */
28
+ function useScreenDimensions() {
29
+ const [dimensions, setDimensions] = useState(() => Dimensions.get('window'));
30
+
31
+ useEffect(() => {
32
+ const subscription = Dimensions.addEventListener('change', ({ window }) => {
33
+ setDimensions(window);
34
+ });
35
+ return () => subscription.remove();
36
+ }, []);
37
+
38
+ return dimensions;
39
+ }
40
+
41
+ const SPRING_CONFIG = {
42
+ damping: 25,
43
+ stiffness: 300,
44
+ mass: 0.8,
45
+ };
46
+
47
+ export interface BottomSheetRef {
48
+ present: () => void;
49
+ dismiss: () => void;
50
+ close: () => void;
51
+ expand: () => void;
52
+ collapse: () => void;
53
+ scrollTo: (y: number, animated?: boolean) => void;
54
+ }
55
+
56
+ export interface BottomSheetProps {
57
+ children: React.ReactNode;
58
+ onDismiss?: () => void;
59
+ enablePanDownToClose?: boolean;
60
+ backgroundComponent?: (props: { style?: StyleProp<ViewStyle> }) => React.ReactElement | null;
61
+ backdropComponent?: (props: { style?: StyleProp<ViewStyle>; onPress?: () => void }) => React.ReactElement | null;
62
+ /**
63
+ * Style applied to the sheet container (the outer Animated.View positioned at
64
+ * the bottom of the screen). Use this to override `maxWidth`, `height`,
65
+ * background color, border radius, etc. Composed AFTER the internal sheet
66
+ * styles so it can override them.
67
+ */
68
+ style?: StyleProp<ViewStyle>;
69
+ enableHandlePanningGesture?: boolean;
70
+ onDismissAttempt?: () => boolean;
71
+ detached?: boolean; // If true, shows with margins and rounded corners. If false, full width with rounded top only.
72
+ /**
73
+ * Whether to render the built-in (non-interactive) drag handle bar at the top
74
+ * of the sheet. Defaults to `true`. Set to `false` when the consumer renders
75
+ * its own handle (e.g. an interactive close affordance) inside `children`.
76
+ */
77
+ showHandle?: boolean;
78
+ /**
79
+ * Opacity of the dimming backdrop behind the sheet (0–1). Defaults to `0.5`.
80
+ * Set to a higher value (e.g. `0.7`) when the sheet is presented over another
81
+ * bottom sheet (Dialog cases) so the underlying handle/content does not
82
+ * bleed through.
83
+ */
84
+ backdropOpacity?: number;
85
+ /**
86
+ * When `true` (default), children are wrapped in an internal scrollable
87
+ * container — convenient for vertical content that can overflow.
88
+ *
89
+ * Set to `false` when the screen owns its own scrolling primitive
90
+ * (e.g. a `FlatList`, `SectionList`, or any other VirtualizedList).
91
+ * Nesting a VirtualizedList inside the internal ScrollView would break
92
+ * windowing/keyboard handling and trigger a React Native warning. In
93
+ * non-scrollable mode the screen receives the full available height
94
+ * (minus the drag handle) and must manage its own overflow.
95
+ */
96
+ scrollable?: boolean;
97
+ /**
98
+ * When `true`, the body pan uses RNGH's `manualActivation` and only
99
+ * activates when (a) the inner ScrollView is at the top AND (b) the user
100
+ * has moved their finger downward by > 8dp. This is the @gorhom/bottom-sheet
101
+ * coordination model — recommended for sheets containing scrollable content
102
+ * on Android (avoids stealing vertical events from the inner scroller).
103
+ *
104
+ * When `false` (default), the body pan is always active and gates on the
105
+ * scroll offset at `onStart` time. This is the legacy behavior, preserved
106
+ * for backwards compatibility with current bloom consumers.
107
+ *
108
+ * Enabling this also splits the drag handle into its own dedicated,
109
+ * unconditional pan so users can always grab the handle to drag — even
110
+ * when the inner ScrollView is mid-scroll.
111
+ */
112
+ manualActivation?: boolean;
113
+ /**
114
+ * When `true`, the backdrop dims proportionally with drag distance — the
115
+ * overlay fades from full opacity (sheet at rest) to 30% as the sheet is
116
+ * pulled down 40% of the screen height. iOS Photos style. The base
117
+ * `backdropOpacity` still controls the resting dim level.
118
+ *
119
+ * Defaults to `false` (constant opacity during drag).
120
+ */
121
+ dynamicBackdrop?: boolean;
122
+ /**
123
+ * Custom handle slot. When provided, replaces the default drag handle
124
+ * (the 36×5 pill). The rendered handle is wrapped in the dedicated handle
125
+ * gesture detector (when `manualActivation` is `true`) so it remains
126
+ * unconditionally draggable. `showHandle={false}` still suppresses any
127
+ * handle rendering — `handleComponent` is only consulted when
128
+ * `showHandle` is `true`.
129
+ */
130
+ handleComponent?: () => React.ReactNode;
131
+ }
132
+
133
+ /**
134
+ * Props consumed by a platform Shell — the outermost wrapper that hosts the
135
+ * sheet body in a full-screen overlay. Native wraps it in RN's `<Modal>` +
136
+ * `<KeyboardProvider>` + `<GestureHandlerRootView>`; web wraps it in bloom's
137
+ * stable DOM `<Portal>` + a fixed `<GestureHandlerRootView>`. Splitting the
138
+ * shell is what lets web avoid RN-Web's `<Modal>`/`ModalPortal`, whose host
139
+ * node is orphaned under React 19 concurrent/StrictMode so the sheet mounts
140
+ * but never paints.
141
+ */
142
+ export interface BottomSheetShellProps {
143
+ /** True while the sheet is mounted (open or animating closed). */
144
+ visible: boolean;
145
+ /** Hardware/back-button dismissal request (native `<Modal onRequestClose>`). */
146
+ onRequestClose: () => void;
147
+ /**
148
+ * Live keyboard height, driven by the native shell's keyboard tracker. Web
149
+ * shells ignore it (the browser owns keyboard layout) and it stays `0`.
150
+ */
151
+ keyboardHeight: SharedValue<number>;
152
+ children: React.ReactNode;
153
+ }
154
+
155
+ /**
156
+ * Platform-agnostic bottom-sheet core: all gesture, animation, scroll, and
157
+ * dismissal logic lives here so native and web share ONE implementation. The
158
+ * only thing that differs per platform is the `Shell` (see
159
+ * `BottomSheetShellProps`) — injected by `index.tsx` (native) and
160
+ * `index.web.tsx` (web). This module imports nothing platform-specific
161
+ * (no RN `<Modal>`, no `react-native-keyboard-controller`) so it is safe to
162
+ * bundle on web.
163
+ */
164
+ export interface BottomSheetBaseProps extends BottomSheetProps {
165
+ Shell: React.ComponentType<BottomSheetShellProps>;
166
+ }
167
+
168
+ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: React.ForwardedRef<BottomSheetRef>) => {
169
+ const {
170
+ Shell,
171
+ children,
172
+ onDismiss,
173
+ enablePanDownToClose = true,
174
+ backgroundComponent,
175
+ backdropComponent,
176
+ style,
177
+ enableHandlePanningGesture = true,
178
+ onDismissAttempt,
179
+ detached = false,
180
+ showHandle = true,
181
+ backdropOpacity = 0.5,
182
+ scrollable = true,
183
+ manualActivation = false,
184
+ dynamicBackdrop = false,
185
+ handleComponent,
186
+ } = props;
187
+
188
+ const insets = useSafeAreaInsets();
189
+ const theme = useTheme();
190
+ const { colors } = theme;
191
+ const { height: screenHeight } = useScreenDimensions();
192
+ const [visible, setVisible] = useState(false);
193
+ const [rendered, setRendered] = useState(false); // keep mounted for exit animation
194
+ const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
195
+ const hasClosedRef = useRef(false);
196
+ const scrollViewRef = useRef<Animated.ScrollView>(null);
197
+ /**
198
+ * Monotonically increasing counter that identifies "the current close
199
+ * attempt". Bumped every time the sheet re-opens (`present()`), so any
200
+ * in-flight `withTiming` completion callback or fallback timer from a
201
+ * PREVIOUS close cycle becomes a no-op. Without this guard, a stale
202
+ * `runOnJS(finishClose)` from an aborted close would fire `onDismiss`
203
+ * and unmount the sheet immediately after the user opens it again,
204
+ * causing "tap to open does nothing" reports in production.
205
+ */
206
+ const closeGenerationRef = useRef(0);
207
+
208
+ const screenHeightSV = useSharedValue(screenHeight);
209
+ // Keep shared value in sync when screen dimensions change (rotation/resize)
210
+ useEffect(() => {
211
+ screenHeightSV.value = screenHeight;
212
+ }, [screenHeight, screenHeightSV]);
213
+
214
+ const translateY = useSharedValue(screenHeight);
215
+ const opacity = useSharedValue(0);
216
+ const scrollOffsetY = useSharedValue(0);
217
+ const isScrollAtTop = useSharedValue(true);
218
+ const allowPanClose = useSharedValue(true);
219
+ // Live keyboard height. Written by the native shell's keyboard tracker via
220
+ // this shared value; on web it stays 0 (the browser owns keyboard layout).
221
+ const keyboardHeight = useSharedValue(0);
222
+ // Stores the sheet's `translateY` captured at the moment a pan gesture
223
+ // begins, so `onUpdate` can offset from the starting position. This MUST
224
+ // be a primitive shared value, not an object-valued one. Under
225
+ // react-native-worklets 0.8.x (Reanimated 4), assigning a fresh object
226
+ // literal to an object-valued shared value's `.value` inside a worklet
227
+ // freezes the object (`freezeObjectInDev`) and routes the mutation through
228
+ // the serializer's listener machinery — which then tries to call the
229
+ // non-worklet `removeListener` synchronously on the UI thread and crashes
230
+ // ("Tried to synchronously call a non-worklet function `removeListener`").
231
+ // A scalar shared value sidesteps all object serialization.
232
+ const contextY = useSharedValue(0);
233
+ // Mirror of `closeGenerationRef` for worklet access. Bumped from the JS
234
+ // thread in lockstep with the ref so gesture worklets always see the
235
+ // current generation when they snapshot it on `onEnd`.
236
+ const closeGeneration = useSharedValue(0);
237
+ // Used by `manualActivation` body pan to track the touch's initial Y so
238
+ // it can compute downward distance for the activation decision.
239
+ const touchStartY = useSharedValue(0);
240
+
241
+ // Refs used to mark the handle pan and the body pan as mutually
242
+ // simultaneous (manualActivation mode only). Without this RNGH treats them
243
+ // as racing gestures and a touch that begins in the handle area could be
244
+ // claimed by whichever recognizer activates first — leading to
245
+ // inconsistent drag start.
246
+ const bodyPanRef = useRef<GestureType | undefined>(undefined);
247
+ const handlePanRef = useRef<GestureType | undefined>(undefined);
248
+
249
+ // Dismiss callbacks
250
+ const safeClose = useCallback(() => {
251
+ if (onDismissAttempt?.()) {
252
+ onDismiss?.();
253
+ } else if (!onDismissAttempt) {
254
+ onDismiss?.();
255
+ }
256
+ }, [onDismissAttempt, onDismiss]);
257
+
258
+ // Mirror `safeClose` and `rendered` into refs so the unmount cleanup can
259
+ // fire the latest dismiss callback when needed, without re-binding the
260
+ // cleanup effect on every render.
261
+ const safeCloseRef = useRef(safeClose);
262
+ useEffect(() => {
263
+ safeCloseRef.current = safeClose;
264
+ }, [safeClose]);
265
+ const renderedRef = useRef(rendered);
266
+ useEffect(() => {
267
+ renderedRef.current = rendered;
268
+ }, [rendered]);
269
+
270
+ /**
271
+ * Commit a close. Two guards prevent stale callbacks from firing:
272
+ * 1. `hasClosedRef` — protects against the fallback timer AND the
273
+ * animation callback both racing to call us within a single close
274
+ * cycle.
275
+ * 2. `generation` — protects against a callback from a PREVIOUS close
276
+ * cycle firing AFTER the user reopened. If the live generation has
277
+ * advanced past the one captured when the close started, this
278
+ * callback is from a cycle that the user has implicitly cancelled
279
+ * by reopening — silently drop it.
280
+ */
281
+ const finishClose = useCallback((generation: number) => {
282
+ if (closeGenerationRef.current !== generation) return;
283
+ if (hasClosedRef.current) return;
284
+ hasClosedRef.current = true;
285
+ safeClose();
286
+ setRendered(false);
287
+ }, [safeClose]);
288
+
289
+ useEffect(() => {
290
+ if (visible) {
291
+ if (closeTimeoutRef.current) {
292
+ clearTimeout(closeTimeoutRef.current);
293
+ closeTimeoutRef.current = null;
294
+ }
295
+ hasClosedRef.current = false;
296
+ // Bump generation: any pending close-completion callback from a
297
+ // prior cycle (animation or fallback timer) will now no-op when
298
+ // it eventually fires, because its captured generation is stale.
299
+ closeGenerationRef.current += 1;
300
+ closeGeneration.value = closeGenerationRef.current;
301
+ opacity.value = withTiming(1, { duration: 250 });
302
+ translateY.value = withSpring(0, SPRING_CONFIG);
303
+ } else if (rendered) {
304
+ // Capture the generation for THIS close cycle so the animation
305
+ // callback (running on the UI thread, scheduled back to JS) and
306
+ // the fallback timer agree on which cycle they belong to.
307
+ const generation = closeGenerationRef.current;
308
+ opacity.value = withTiming(0, { duration: 250 }, (finished) => {
309
+ if (finished) {
310
+ runOnJS(finishClose)(generation);
311
+ }
312
+ });
313
+ translateY.value = withSpring(screenHeight, { ...SPRING_CONFIG, stiffness: 250 });
314
+
315
+ // Fallback timer to ensure close completes (especially on web
316
+ // where reanimated callbacks occasionally drop on tab blur).
317
+ if (closeTimeoutRef.current) {
318
+ clearTimeout(closeTimeoutRef.current);
319
+ }
320
+ closeTimeoutRef.current = setTimeout(() => {
321
+ finishClose(generation);
322
+ closeTimeoutRef.current = null;
323
+ }, 300);
324
+ }
325
+ }, [visible, rendered, finishClose, screenHeight, closeGeneration, opacity, translateY]);
326
+
327
+ // On unmount: ensure pending close callbacks (e.g. consumer's `onDismiss`)
328
+ // still fire if the BS is yanked mid-animation by a parent re-render.
329
+ // Without this, `Dialog`'s `handleDismiss` never runs and queued
330
+ // callbacks (post-close handlers) are silently lost.
331
+ // Only fires when the sheet was actually rendered (open or closing) to
332
+ // avoid spuriously calling onDismiss on bare unmount of a never-opened
333
+ // sheet. Refs are read inside the cleanup, so latest values are captured.
334
+ useEffect(() => () => {
335
+ if (closeTimeoutRef.current) {
336
+ clearTimeout(closeTimeoutRef.current);
337
+ closeTimeoutRef.current = null;
338
+ }
339
+ if (renderedRef.current && !hasClosedRef.current) {
340
+ hasClosedRef.current = true;
341
+ safeCloseRef.current();
342
+ }
343
+ }, []);
344
+
345
+ // Apply web scrollbar styles when colors change; clean up on unmount
346
+ useEffect(() => {
347
+ if (Platform.OS === 'web') {
348
+ createWebScrollbarStyle(colors.border);
349
+ }
350
+ return () => {
351
+ removeWebScrollbarStyle();
352
+ };
353
+ }, [colors.border]);
354
+
355
+ const present = useCallback(() => {
356
+ setRendered(true);
357
+ setVisible(true);
358
+ }, []);
359
+ const dismiss = useCallback(() => {
360
+ setVisible(false);
361
+ }, []);
362
+
363
+ const scrollTo = useCallback((y: number, animated = true) => {
364
+ scrollViewRef.current?.scrollTo({ y, animated });
365
+ }, []);
366
+
367
+ useImperativeHandle(ref, () => ({
368
+ present,
369
+ dismiss,
370
+ close: dismiss,
371
+ expand: present,
372
+ collapse: dismiss,
373
+ scrollTo,
374
+ }), [present, dismiss, scrollTo]);
375
+
376
+ const nativeGesture = useMemo(() => Gesture.Native(), []);
377
+
378
+ // Body pan — two strategies, switched by `manualActivation`.
379
+ //
380
+ // (1) Legacy mode (`manualActivation: false`, the bloom 0.3.x default):
381
+ // Pan is always active. We snapshot the scroll offset at `onStart`
382
+ // and gate movement on `scrollOffsetY <= 8` during the gesture.
383
+ // This is what current bloom consumers expect.
384
+ //
385
+ // (2) gorhom-style mode (`manualActivation: true`):
386
+ // Pan uses `manualActivation(true)` and only flips to active when
387
+ // the inner ScrollView is at the top AND the user has moved their
388
+ // finger downward > 8dp. In every other case it fails, so the
389
+ // ScrollView keeps full ownership of the touch. This is the only
390
+ // RNGH 2.x pattern that does not steal vertical events from the
391
+ // inner scroller on Android. Required for FileManagement /
392
+ // PhotoPicker style sheets.
393
+ const panGesture = useMemo(() => {
394
+ if (manualActivation) {
395
+ return Gesture.Pan()
396
+ .enabled(enablePanDownToClose)
397
+ .withRef(bodyPanRef)
398
+ .manualActivation(true)
399
+ .simultaneousWithExternalGesture(scrollViewRef, handlePanRef)
400
+ .onTouchesDown((e) => {
401
+ 'worklet';
402
+ const t = e.changedTouches[0];
403
+ if (t) touchStartY.value = t.absoluteY;
404
+ contextY.value = translateY.value;
405
+ })
406
+ .onTouchesMove((e, state) => {
407
+ 'worklet';
408
+ const t = e.changedTouches[0];
409
+ if (!t) return;
410
+ const dy = t.absoluteY - touchStartY.value;
411
+ const atTop = scrollOffsetY.value <= 4;
412
+ // Activate only when (at scroll top) AND (finger has moved
413
+ // downward by > 8dp). Any other motion: fail so the
414
+ // ScrollView claims the gesture.
415
+ if (atTop && dy > 8) {
416
+ state.activate();
417
+ } else if (dy < -4 || !atTop) {
418
+ state.fail();
419
+ }
420
+ })
421
+ .onUpdate((event) => {
422
+ 'worklet';
423
+ if (event.translationY < 0) return;
424
+ const newTranslateY = contextY.value + event.translationY;
425
+ if (newTranslateY >= 0) {
426
+ translateY.value = newTranslateY;
427
+ }
428
+ })
429
+ .onEnd((event) => {
430
+ 'worklet';
431
+ const velocity = event.velocityY;
432
+ const distance = translateY.value;
433
+ const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
434
+ const fastSwipeThreshold = 900;
435
+ const shouldClose =
436
+ velocity > fastSwipeThreshold ||
437
+ (distance > closeThreshold && velocity > -300);
438
+
439
+ if (shouldClose) {
440
+ // Snapshot the generation on the UI thread at the
441
+ // moment the close gesture commits. The completion
442
+ // callback only fires `finishClose` if no reopen
443
+ // bumped the generation in between.
444
+ const generation = closeGeneration.value;
445
+ translateY.value = withSpring(screenHeightSV.value, { ...SPRING_CONFIG, velocity });
446
+ opacity.value = withTiming(0, { duration: 250 }, (finished) => {
447
+ if (finished) runOnJS(finishClose)(generation);
448
+ });
449
+ } else {
450
+ translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
451
+ }
452
+ });
453
+ }
454
+
455
+ // Legacy always-active pan (bloom 0.3.x behaviour).
456
+ return Gesture.Pan()
457
+ .enabled(enablePanDownToClose)
458
+ .simultaneousWithExternalGesture(nativeGesture)
459
+ .onStart(() => {
460
+ 'worklet';
461
+ contextY.value = translateY.value;
462
+ allowPanClose.value = scrollOffsetY.value <= 8;
463
+ })
464
+ .onUpdate((event) => {
465
+ 'worklet';
466
+ if (!allowPanClose.value) {
467
+ return;
468
+ }
469
+ const newTranslateY = contextY.value + event.translationY;
470
+ // If user is scrolling down while content isn't at (or near) the top, let ScrollView handle it
471
+ const atTopOrNearTop = scrollOffsetY.value <= 8; // slightly larger tolerance for smoother handoff
472
+ if (event.translationY > 0 && !atTopOrNearTop) {
473
+ return;
474
+ }
475
+ if (newTranslateY >= 0) {
476
+ translateY.value = newTranslateY;
477
+ } else if (detached) {
478
+ // Only allow overdrag (pulling up beyond top) when detached
479
+ translateY.value = newTranslateY * 0.3;
480
+ } else {
481
+ // In normal mode, prevent overdrag - clamp to 0
482
+ translateY.value = 0;
483
+ }
484
+ })
485
+ .onEnd((event) => {
486
+ 'worklet';
487
+ if (!allowPanClose.value) {
488
+ return;
489
+ }
490
+ const velocity = event.velocityY;
491
+ const distance = translateY.value;
492
+ // Require a deeper pull to close (more like native bottom sheets)
493
+ const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
494
+ const fastSwipeThreshold = 900;
495
+ const shouldClose =
496
+ velocity > fastSwipeThreshold ||
497
+ (distance > closeThreshold && velocity > -300);
498
+
499
+ if (shouldClose) {
500
+ const generation = closeGeneration.value;
501
+ translateY.value = withSpring(screenHeightSV.value, {
502
+ ...SPRING_CONFIG,
503
+ velocity: velocity,
504
+ });
505
+ opacity.value = withTiming(0, { duration: 250 }, (finished) => {
506
+ if (finished) {
507
+ runOnJS(finishClose)(generation);
508
+ }
509
+ });
510
+ } else {
511
+ translateY.value = withSpring(0, {
512
+ ...SPRING_CONFIG,
513
+ velocity: velocity,
514
+ });
515
+ }
516
+ });
517
+ // Shared values are stable refs; the listed deps are the only JS-side
518
+ // values that change the gesture's behavior. `finishClose` is stable
519
+ // (useCallback with stable deps).
520
+ // eslint-disable-next-line react-hooks/exhaustive-deps
521
+ }, [enablePanDownToClose, detached, manualActivation, nativeGesture, finishClose]);
522
+
523
+ // Dedicated handle pan — only built in `manualActivation` mode. Always
524
+ // active so users can drag the handle even while content is mid-scroll.
525
+ // In legacy mode the body pan already wraps the whole sheet (handle
526
+ // included), so no separate gesture is needed.
527
+ const handlePanGesture = useMemo(() => {
528
+ if (!manualActivation) return undefined;
529
+ return Gesture.Pan()
530
+ .enabled(enablePanDownToClose && enableHandlePanningGesture)
531
+ .withRef(handlePanRef)
532
+ .simultaneousWithExternalGesture(bodyPanRef)
533
+ .activeOffsetY([-8, 8])
534
+ .onStart(() => {
535
+ 'worklet';
536
+ contextY.value = translateY.value;
537
+ })
538
+ .onUpdate((event) => {
539
+ 'worklet';
540
+ const newTranslateY = contextY.value + event.translationY;
541
+ if (newTranslateY >= 0) {
542
+ translateY.value = newTranslateY;
543
+ } else if (detached) {
544
+ translateY.value = newTranslateY * 0.3;
545
+ } else {
546
+ translateY.value = 0;
547
+ }
548
+ })
549
+ .onEnd((event) => {
550
+ 'worklet';
551
+ const velocity = event.velocityY;
552
+ const distance = translateY.value;
553
+ const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
554
+ const fastSwipeThreshold = 900;
555
+ const shouldClose =
556
+ velocity > fastSwipeThreshold ||
557
+ (distance > closeThreshold && velocity > -300);
558
+
559
+ if (shouldClose) {
560
+ const generation = closeGeneration.value;
561
+ translateY.value = withSpring(screenHeightSV.value, { ...SPRING_CONFIG, velocity });
562
+ opacity.value = withTiming(0, { duration: 250 }, (finished) => {
563
+ if (finished) runOnJS(finishClose)(generation);
564
+ });
565
+ } else {
566
+ translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
567
+ }
568
+ });
569
+ // eslint-disable-next-line react-hooks/exhaustive-deps
570
+ }, [manualActivation, enablePanDownToClose, enableHandlePanningGesture, detached, finishClose]);
571
+
572
+ // CRITICAL — the shared values each `useAnimatedStyle` READS (translateY,
573
+ // opacity, screenHeightSV, keyboardHeight) MUST be listed in its dependency
574
+ // array. On web WITHOUT the react-native-worklets babel plugin (the
575
+ // production reality for the RN-Web apps: console, accounts, the SDK, etc.),
576
+ // reanimated cannot auto-detect which shared values a worklet reads (there
577
+ // is no injected `__closure`), so it drives the style mapper's re-runs off
578
+ // the dependency array instead. With an empty/incomplete deps array the
579
+ // mapper runs ONCE and freezes at the initial frame — the shared value keeps
580
+ // animating underneath but the DOM never updates, so the sheet stays one
581
+ // full viewport below the fold (translateY = screenHeight) and is invisible.
582
+ // Native (with the plugin) auto-tracks and ignores the extra deps, so this
583
+ // is safe on both platforms. Do NOT strip the shared values from these deps.
584
+ //
585
+ // `opacity.value` drives the fade in/out (0 -> 1). `backdropOpacity` is the
586
+ // final dim level once fully visible. We multiply so the consumer-provided
587
+ // dim opacity applies smoothly across the animation. When
588
+ // `dynamicBackdrop` is enabled, the dim also fades proportionally with
589
+ // drag distance (iOS Photos style).
590
+ const backdropStyle = useAnimatedStyle(() => {
591
+ const dragFactor = dynamicBackdrop
592
+ ? interpolate(
593
+ translateY.value,
594
+ [0, screenHeightSV.value * 0.4],
595
+ [1, 0.3],
596
+ 'clamp',
597
+ )
598
+ : 1;
599
+ return {
600
+ opacity: opacity.value * backdropOpacity * dragFactor,
601
+ };
602
+ }, [backdropOpacity, dynamicBackdrop, opacity, translateY, screenHeightSV]);
603
+
604
+ const sheetStyle = useAnimatedStyle(() => {
605
+ const scale = interpolate(translateY.value, [0, screenHeightSV.value], [1, 0.95]);
606
+ return {
607
+ transform: [
608
+ { translateY: translateY.value - keyboardHeight.value },
609
+ { scale },
610
+ ],
611
+ };
612
+ }, [translateY, screenHeightSV, keyboardHeight]);
613
+
614
+ const sheetHeightStyle = useAnimatedStyle(() => ({
615
+ maxHeight: screenHeightSV.value - keyboardHeight.value - insets.top - (detached ? insets.bottom + 16 : 0),
616
+ }), [insets.top, insets.bottom, detached, screenHeightSV, keyboardHeight]);
617
+
618
+ const sheetMarginStyle = useAnimatedStyle(() => {
619
+ // Only add margin when detached, otherwise extend behind safe area
620
+ if (detached) {
621
+ return {
622
+ marginBottom: keyboardHeight.value > 0 ? 16 : insets.bottom + 16,
623
+ };
624
+ }
625
+ return {
626
+ marginBottom: 0,
627
+ };
628
+ }, [insets.bottom, detached, keyboardHeight]);
629
+
630
+ const handleBackdropPress = useCallback(() => {
631
+ // Always animate close on backdrop press
632
+ if (onDismissAttempt && !onDismissAttempt()) {
633
+ return;
634
+ }
635
+ dismiss();
636
+ }, [onDismissAttempt, dismiss]);
637
+
638
+ const scrollHandler = useAnimatedScrollHandler({
639
+ onScroll: (event) => {
640
+ scrollOffsetY.value = event.contentOffset.y;
641
+ isScrollAtTop.value = event.contentOffset.y <= 0;
642
+ },
643
+ }, []);
644
+
645
+ const dynamicStyles = useMemo(() => {
646
+ return StyleSheet.create({
647
+ handle: {
648
+ ...styles.handle,
649
+ backgroundColor: theme.isDark ? '#444' : '#C7C7CC',
650
+ },
651
+ sheet: {
652
+ ...styles.sheet,
653
+ backgroundColor: colors.background,
654
+ ...(detached ? styles.sheetDetached : styles.sheetNormal),
655
+ },
656
+ scrollContent: {
657
+ ...styles.scrollContent,
658
+ // In normal mode, don't add padding here - screens handle their own padding
659
+ // The sheet extends behind safe area, and screens add padding as needed
660
+ },
661
+ });
662
+ }, [colors.background, theme.isDark, detached]);
663
+
664
+ // Publish the sheet's keyboard shared value to the shell so the native
665
+ // shell can drive it from inside its own <KeyboardProvider>. Kept stable
666
+ // (shared values are stable refs) so the shell never re-mounts for this.
667
+ if (!rendered) return null;
668
+
669
+ // Default handle render — used when `handleComponent` is not provided.
670
+ const renderDefaultHandle = () => <View style={dynamicStyles.handle} />;
671
+ const handleNode = showHandle ? (handleComponent ? handleComponent() : renderDefaultHandle()) : null;
672
+
673
+ // In `manualActivation` mode the handle gets its own gesture detector
674
+ // sitting in a dedicated absolutely-positioned hit area at the top of the
675
+ // sheet. In legacy mode the handle is rendered inline as a decorative
676
+ // overlay (the body pan covers the entire sheet, handle included).
677
+ const handleSlot = handleNode && manualActivation && handlePanGesture ? (
678
+ <GestureDetector gesture={handlePanGesture}>
679
+ <View style={styles.handleHitArea} accessible accessibilityRole="adjustable">
680
+ {handleNode}
681
+ </View>
682
+ </GestureDetector>
683
+ ) : handleNode;
684
+
685
+ // Inner content: scrollable wraps in Animated.ScrollView, non-scrollable
686
+ // renders children directly. In legacy mode the scrollview is also
687
+ // wrapped in the `nativeGesture` detector for scroll/pan coordination.
688
+ const scrollViewNode = (
689
+ <Animated.ScrollView
690
+ ref={scrollViewRef}
691
+ style={[
692
+ styles.scrollView,
693
+ Platform.OS === 'web' && ({
694
+ scrollbarWidth: 'thin',
695
+ scrollbarColor: `${colors.border} transparent`,
696
+ } as ViewStyle),
697
+ ]}
698
+ contentContainerStyle={dynamicStyles.scrollContent}
699
+ showsVerticalScrollIndicator={false}
700
+ keyboardShouldPersistTaps="handled"
701
+ onScroll={scrollHandler}
702
+ scrollEventThrottle={16}
703
+ {...(Platform.OS === 'web' ? { className: 'bottom-sheet-scrollview' } : undefined)}
704
+ onLayout={() => {
705
+ if (Platform.OS === 'web') {
706
+ createWebScrollbarStyle(colors.border);
707
+ }
708
+ }}
709
+ >
710
+ {children}
711
+ </Animated.ScrollView>
712
+ );
713
+
714
+ const bodyContent = scrollable
715
+ ? (manualActivation
716
+ // In manualActivation mode the scroll view is referenced by the
717
+ // pan's simultaneous list directly; no wrapping native gesture.
718
+ ? scrollViewNode
719
+ // Legacy mode: native gesture wraps the scroll view to coordinate
720
+ // with the always-active body pan.
721
+ : <GestureDetector gesture={nativeGesture}>{scrollViewNode}</GestureDetector>)
722
+ : <View style={styles.nonScrollableContent}>{children}</View>;
723
+
724
+ return (
725
+ <Shell visible={rendered} onRequestClose={dismiss} keyboardHeight={keyboardHeight}>
726
+ <View style={StyleSheet.absoluteFill}>
727
+ <Animated.View style={[styles.backdrop, backdropStyle]}>
728
+ {backdropComponent ? (
729
+ backdropComponent({ onPress: handleBackdropPress })
730
+ ) : (
731
+ <Pressable style={styles.backdropTouchable} onPress={handleBackdropPress}>
732
+ <View style={StyleSheet.absoluteFill} />
733
+ </Pressable>
734
+ )}
735
+ </Animated.View>
736
+
737
+ <GestureDetector gesture={panGesture}>
738
+ <Animated.View style={[dynamicStyles.sheet, sheetMarginStyle, sheetStyle, sheetHeightStyle, style]}>
739
+ {backgroundComponent?.({ style: styles.background })}
740
+
741
+ {handleSlot}
742
+
743
+ {bodyContent}
744
+ </Animated.View>
745
+ </GestureDetector>
746
+ </View>
747
+ </Shell>
748
+ );
749
+ });
750
+
751
+ BottomSheetBase.displayName = 'BottomSheetBase';
752
+
753
+ const styles = StyleSheet.create({
754
+ backdrop: {
755
+ // Color is solid black; the dim level is driven by `backdropOpacity` via
756
+ // the animated `opacity` style on the wrapping <Animated.View>.
757
+ flex: 1,
758
+ backgroundColor: '#000',
759
+ },
760
+ backdropTouchable: {
761
+ flex: 1,
762
+ },
763
+ sheet: {
764
+ position: 'absolute',
765
+ bottom: 0,
766
+ overflow: 'hidden',
767
+ maxWidth: 800,
768
+ alignSelf: 'center',
769
+ marginHorizontal: 'auto',
770
+ },
771
+ sheetDetached: {
772
+ left: 16,
773
+ right: 16,
774
+ borderRadius: 24,
775
+ },
776
+ sheetNormal: {
777
+ left: 0,
778
+ right: 0,
779
+ borderTopLeftRadius: 24,
780
+ borderTopRightRadius: 24,
781
+ },
782
+ /** Legacy (non-manualActivation) handle: decorative overlay only. */
783
+ handle: {
784
+ position: 'absolute',
785
+ top: 10,
786
+ left: '50%',
787
+ marginLeft: -18,
788
+ width: 36,
789
+ height: 5,
790
+ borderRadius: 3,
791
+ zIndex: Z_INDEX.sheetHandle,
792
+ },
793
+ /**
794
+ * Hit area for the drag handle in `manualActivation` mode. Absolutely
795
+ * positioned at the top of the sheet so the area visually "floats" above
796
+ * the content — content scrolls up underneath it (no layout offset)
797
+ * while the thumb can still grab the full-width 28dp strip to drag.
798
+ */
799
+ handleHitArea: {
800
+ position: 'absolute',
801
+ top: 0,
802
+ left: 0,
803
+ right: 0,
804
+ height: 28,
805
+ alignItems: 'center',
806
+ justifyContent: 'flex-start',
807
+ paddingTop: 6,
808
+ zIndex: Z_INDEX.sheetHandle,
809
+ },
810
+ background: {
811
+ ...StyleSheet.absoluteFill,
812
+ },
813
+ scrollView: {
814
+ flex: 1,
815
+ },
816
+ scrollContent: {
817
+ flexGrow: 1,
818
+ },
819
+ nonScrollableContent: {
820
+ flex: 1,
821
+ },
822
+ });
823
+
824
+ // Create web scrollbar styles dynamically based on theme
825
+ const createWebScrollbarStyle = (borderColor: string) => {
826
+ if (Platform.OS !== 'web' || typeof document === 'undefined') return;
827
+
828
+ let styleElement = document.getElementById(SCROLLBAR_STYLE_ID) as HTMLStyleElement | null;
829
+
830
+ if (!styleElement) {
831
+ styleElement = document.createElement('style');
832
+ styleElement.id = SCROLLBAR_STYLE_ID;
833
+ document.head.appendChild(styleElement);
834
+ }
835
+
836
+ // Derive a slightly darker scrollbar hover color from the border color
837
+ const scrollbarColor = borderColor;
838
+ const scrollbarHoverColor = borderColor.startsWith('hsl')
839
+ ? borderColor.replace(/\)$/, ' / 0.7)') // add alpha for hover
840
+ : '#888';
841
+
842
+ styleElement.textContent = `
843
+ .bottom-sheet-scrollview::-webkit-scrollbar {
844
+ width: 6px;
845
+ }
846
+ .bottom-sheet-scrollview::-webkit-scrollbar-track {
847
+ background: transparent;
848
+ border-radius: 10px;
849
+ }
850
+ .bottom-sheet-scrollview::-webkit-scrollbar-thumb {
851
+ background: ${scrollbarColor};
852
+ border-radius: 10px;
853
+ }
854
+ .bottom-sheet-scrollview::-webkit-scrollbar-thumb:hover {
855
+ background: ${scrollbarHoverColor};
856
+ }
857
+ `;
858
+ };
859
+
860
+ const SCROLLBAR_STYLE_ID = 'bottom-sheet-scrollbar-style';
861
+
862
+ /** Remove the injected scrollbar <style> tag on unmount. */
863
+ const removeWebScrollbarStyle = () => {
864
+ if (Platform.OS !== 'web' || typeof document === 'undefined') return;
865
+ document.getElementById(SCROLLBAR_STYLE_ID)?.remove();
866
+ };