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