@buoy-gg/bottom-sheet 1.7.2

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.
@@ -0,0 +1,1158 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.BottomSheet = void 0;
7
+ var _react = require("react");
8
+ var _reactNative = require("react-native");
9
+ var _jsxRuntime = require("react/jsx-runtime");
10
+ /**
11
+ * BottomSheet - Ultra-optimized modal component for true 60FPS performance
12
+ *
13
+ * Achieves 60FPS by following key principles:
14
+ * 1. ALWAYS use native driver (useNativeDriver: true)
15
+ * 2. Use transforms instead of layout properties (translateY instead of height)
16
+ * 3. Use interpolation for all calculations (no JS thread math)
17
+ * 4. Minimize PanResponder JS work (direct setValue, no state updates)
18
+ *
19
+ * Based on the proven JsModal component from @buoy-gg/shared-ui
20
+ */
21
+
22
+ // ============================================================================
23
+ // CONSTANTS - Modal dimensions and configuration
24
+ // ============================================================================
25
+ const SCREEN = _reactNative.Dimensions.get("window");
26
+ const MIN_HEIGHT = 100;
27
+ const DEFAULT_HEIGHT = 400;
28
+ const FLOATING_WIDTH = 380;
29
+ const FLOATING_HEIGHT = 500;
30
+ const FLOATING_MIN_WIDTH = SCREEN.width * 0.25; // 1/4 of screen width
31
+ const FLOATING_MIN_HEIGHT = 80; // Just a bit more than header height (60px header + 20px content)
32
+
33
+ // ============================================================================
34
+ // SAFE AREA INSETS - Simplified version for standalone package
35
+ // ============================================================================
36
+
37
+ // Device detection map for iOS
38
+ const iPhoneDimensionMap = {
39
+ // iPhone 14 Pro, 14 Pro Max, 15, 15 Plus, 15 Pro, 15 Pro Max, 16 series (Dynamic Island)
40
+ "393,852": {
41
+ top: 59,
42
+ bottom: 34
43
+ },
44
+ // 14 Pro, 15, 15 Pro, 16, 16 Pro
45
+ "430,932": {
46
+ top: 59,
47
+ bottom: 34
48
+ },
49
+ // 14 Pro Max, 15 Plus, 15 Pro Max, 16 Plus, 16 Pro Max
50
+ // iPhone 12, 12 Pro, 13, 13 Pro, 14
51
+ "390,844": {
52
+ top: 47,
53
+ bottom: 34
54
+ },
55
+ // iPhone 12 Pro Max, 13 Pro Max, 14 Plus
56
+ "428,926": {
57
+ top: 47,
58
+ bottom: 34
59
+ },
60
+ // iPhone 12 mini, 13 mini
61
+ "375,812": {
62
+ top: 50,
63
+ bottom: 34
64
+ },
65
+ // iPhone XR, 11
66
+ "414,896": {
67
+ top: 48,
68
+ bottom: 34
69
+ }
70
+ };
71
+ const getPureJSSafeAreaInsets = () => {
72
+ if (_reactNative.Platform.OS === "android") {
73
+ const androidVersion = _reactNative.Platform.Version;
74
+ const statusBarHeight = _reactNative.StatusBar.currentHeight || 0;
75
+ const hasGestureNav = androidVersion >= 29;
76
+ return {
77
+ top: statusBarHeight,
78
+ bottom: hasGestureNav ? 20 : 0,
79
+ left: 0,
80
+ right: 0
81
+ };
82
+ }
83
+
84
+ // iOS
85
+ const {
86
+ width,
87
+ height
88
+ } = _reactNative.Dimensions.get("window");
89
+ const dimensionKey = `${width},${height}`;
90
+ const deviceInsets = iPhoneDimensionMap[dimensionKey];
91
+ if (deviceInsets) {
92
+ return {
93
+ ...deviceInsets,
94
+ left: 0,
95
+ right: 0
96
+ };
97
+ }
98
+
99
+ // Default for older iPhones without notch
100
+ return {
101
+ top: 20,
102
+ // Standard status bar
103
+ bottom: 0,
104
+ left: 0,
105
+ right: 0
106
+ };
107
+ };
108
+ const useSafeAreaInsets = () => {
109
+ const [insets, setInsets] = (0, _react.useState)(() => getPureJSSafeAreaInsets());
110
+ (0, _react.useEffect)(() => {
111
+ const updateInsets = () => {
112
+ setInsets(getPureJSSafeAreaInsets());
113
+ };
114
+ const subscription = _reactNative.Dimensions.addEventListener("change", updateInsets);
115
+ return () => {
116
+ subscription?.remove();
117
+ };
118
+ }, []);
119
+ return insets;
120
+ };
121
+
122
+ // ============================================================================
123
+ // DEFAULT THEME - Simple, customizable color palette
124
+ // ============================================================================
125
+ const defaultTheme = {
126
+ background: "rgba(8, 12, 21, 0.98)",
127
+ panel: "rgba(16, 22, 35, 0.98)",
128
+ border: "#00B8E666",
129
+ primary: "#FFFFFF",
130
+ secondary: "#B8BFC9",
131
+ muted: "#7A8599",
132
+ success: "#4AFF9F",
133
+ error: "#FF5252",
134
+ info: "#00B8E6"
135
+ };
136
+
137
+ // ============================================================================
138
+ // TYPE DEFINITIONS - Interface contracts for the modal
139
+ // ============================================================================
140
+
141
+ // ============================================================================
142
+ // ICON COMPONENTS - Visual indicators for modal controls
143
+ // ============================================================================
144
+
145
+ /**
146
+ * DragIndicator - Visual feedback for draggable areas
147
+ */
148
+ const DragIndicator = /*#__PURE__*/(0, _react.memo)(function DragIndicator({
149
+ isResizing,
150
+ mode,
151
+ hasCustomContent = false,
152
+ theme = defaultTheme
153
+ }) {
154
+ const styles = createDynamicStyles(theme);
155
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
156
+ style: [styles.dragIndicatorContainer, hasCustomContent && styles.dragIndicatorContainerCustom],
157
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
158
+ style: [styles.dragIndicator, mode === "floating" && styles.floatingDragIndicator, isResizing && styles.dragIndicatorActive]
159
+ }), isResizing && mode === "bottomSheet" && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
160
+ style: styles.resizeGripContainer,
161
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
162
+ style: styles.resizeGripLine
163
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
164
+ style: styles.resizeGripLine
165
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
166
+ style: styles.resizeGripLine
167
+ })]
168
+ })]
169
+ });
170
+ });
171
+
172
+ /**
173
+ * CornerHandle - Resize handle for floating mode corners
174
+ */
175
+ const CornerHandle = /*#__PURE__*/(0, _react.memo)(function CornerHandle({
176
+ isActive,
177
+ theme = defaultTheme
178
+ }) {
179
+ const styles = createDynamicStyles(theme);
180
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
181
+ style: [styles.cornerHandle],
182
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
183
+ style: [styles.handler, isActive && styles.handlerActive]
184
+ })
185
+ });
186
+ });
187
+
188
+ /**
189
+ * ModalHeader - Header bar with title, controls, and drag area
190
+ */
191
+
192
+ const ModalHeader = /*#__PURE__*/(0, _react.memo)(function ModalHeader({
193
+ header,
194
+ onClose,
195
+ onToggleMode,
196
+ isResizing,
197
+ mode,
198
+ panHandlers,
199
+ theme = defaultTheme
200
+ }) {
201
+ const styles = createDynamicStyles(theme);
202
+ const lastTapRef = (0, _react.useRef)(0);
203
+ const tapCountRef = (0, _react.useRef)(0);
204
+ const tapTimeoutRef = (0, _react.useRef)(null);
205
+ const handleHeaderTap = (0, _react.useCallback)(() => {
206
+ const now = Date.now();
207
+ const timeSinceLastTap = now - lastTapRef.current;
208
+ if (timeSinceLastTap > 500) {
209
+ tapCountRef.current = 0;
210
+ }
211
+ tapCountRef.current++;
212
+ lastTapRef.current = now;
213
+ if (tapTimeoutRef.current) {
214
+ clearTimeout(tapTimeoutRef.current);
215
+ }
216
+ tapTimeoutRef.current = setTimeout(() => {
217
+ if (tapCountRef.current === 2) {
218
+ onToggleMode();
219
+ } else if (tapCountRef.current >= 3) {
220
+ onClose();
221
+ }
222
+ tapCountRef.current = 0;
223
+ }, 300);
224
+ }, [onToggleMode, onClose]);
225
+ (0, _react.useEffect)(() => {
226
+ return () => {
227
+ if (tapTimeoutRef.current) {
228
+ clearTimeout(tapTimeoutRef.current);
229
+ }
230
+ };
231
+ }, []);
232
+ const headerProps = panHandlers ? panHandlers : {};
233
+ const shouldHandleTap = !!panHandlers;
234
+
235
+ // If custom content is provided and it's a complete replacement
236
+ if (header?.customContent) {
237
+ const headerContent = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
238
+ style: styles.headerInner,
239
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(DragIndicator, {
240
+ isResizing: isResizing,
241
+ mode: mode,
242
+ hasCustomContent: true,
243
+ theme: theme
244
+ }), header.customContent]
245
+ });
246
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
247
+ style: styles.header,
248
+ ...headerProps,
249
+ children: shouldHandleTap ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableWithoutFeedback, {
250
+ onPress: handleHeaderTap,
251
+ children: headerContent
252
+ }) : headerContent
253
+ });
254
+ }
255
+ const headerContent = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
256
+ style: styles.headerInner,
257
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(DragIndicator, {
258
+ isResizing: isResizing,
259
+ mode: mode,
260
+ theme: theme
261
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
262
+ style: styles.headerContent,
263
+ children: [header?.title && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
264
+ style: styles.headerTitle,
265
+ children: header.title
266
+ }), header?.subtitle && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
267
+ style: styles.headerSubtitle,
268
+ children: header.subtitle
269
+ })]
270
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
271
+ style: styles.headerHintText,
272
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
273
+ style: styles.hintText,
274
+ children: "Double tap: Toggle \u2022 Triple tap: Close"
275
+ })
276
+ })]
277
+ });
278
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
279
+ style: [styles.header, mode === "floating" && styles.floatingModeHeader],
280
+ ...headerProps,
281
+ children: shouldHandleTap ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableWithoutFeedback, {
282
+ onPress: handleHeaderTap,
283
+ children: headerContent
284
+ }) : headerContent
285
+ });
286
+ });
287
+
288
+ // ============================================================================
289
+ // DRAGGABLE HEADER - For floating mode
290
+ // ============================================================================
291
+
292
+ const DraggableHeader = /*#__PURE__*/(0, _react.memo)(function DraggableHeader({
293
+ children,
294
+ position,
295
+ onDragStart,
296
+ onDragEnd,
297
+ onTap,
298
+ containerBounds = _reactNative.Dimensions.get("window"),
299
+ elementSize = {
300
+ width: 100,
301
+ height: 50
302
+ },
303
+ minPosition = {
304
+ x: 0,
305
+ y: 0
306
+ },
307
+ style,
308
+ enabled = true
309
+ }) {
310
+ const isDraggingRef = (0, _react.useRef)(false);
311
+ const dragDistanceRef = (0, _react.useRef)(0);
312
+ const touchOffsetRef = (0, _react.useRef)({
313
+ x: 0,
314
+ y: 0
315
+ });
316
+ const panResponder = (0, _react.useMemo)(() => _reactNative.PanResponder.create({
317
+ onStartShouldSetPanResponder: () => enabled,
318
+ onMoveShouldSetPanResponder: (_, g) => enabled && (Math.abs(g.dx) > 1 || Math.abs(g.dy) > 1),
319
+ onPanResponderTerminationRequest: () => false,
320
+ onPanResponderGrant: evt => {
321
+ isDraggingRef.current = false;
322
+ dragDistanceRef.current = 0;
323
+ touchOffsetRef.current = {
324
+ x: evt.nativeEvent.locationX,
325
+ y: evt.nativeEvent.locationY
326
+ };
327
+ position.stopAnimation(({
328
+ x,
329
+ y
330
+ }) => {
331
+ position.setOffset({
332
+ x,
333
+ y
334
+ });
335
+ position.setValue({
336
+ x: 0,
337
+ y: 0
338
+ });
339
+ });
340
+ },
341
+ onPanResponderMove: (evt, gestureState) => {
342
+ const totalDistance = Math.abs(gestureState.dx) + Math.abs(gestureState.dy);
343
+ dragDistanceRef.current = totalDistance;
344
+ if (totalDistance > 5 && !isDraggingRef.current) {
345
+ isDraggingRef.current = true;
346
+ onDragStart?.();
347
+ }
348
+ const x = evt.nativeEvent.pageX - touchOffsetRef.current.x;
349
+ const y = evt.nativeEvent.pageY - touchOffsetRef.current.y;
350
+ position.setOffset({
351
+ x: 0,
352
+ y: 0
353
+ });
354
+ position.setValue({
355
+ x,
356
+ y
357
+ });
358
+ },
359
+ onPanResponderRelease: () => {
360
+ const currentX = Number(JSON.stringify(position.x));
361
+ const currentY = Number(JSON.stringify(position.y));
362
+ if (dragDistanceRef.current <= 5 && !isDraggingRef.current) {
363
+ position.setOffset({
364
+ x: 0,
365
+ y: 0
366
+ });
367
+ position.setValue({
368
+ x: currentX,
369
+ y: currentY
370
+ });
371
+ onTap?.();
372
+ return;
373
+ }
374
+ const clampedX = Math.max(minPosition.x, Math.min(currentX, containerBounds.width - elementSize.width));
375
+ const clampedY = Math.max(minPosition.y, Math.min(currentY, containerBounds.height - elementSize.height));
376
+ position.setValue({
377
+ x: clampedX,
378
+ y: clampedY
379
+ });
380
+ onDragEnd?.({
381
+ x: clampedX,
382
+ y: clampedY
383
+ });
384
+ isDraggingRef.current = false;
385
+ },
386
+ onPanResponderTerminate: () => {
387
+ isDraggingRef.current = false;
388
+ }
389
+ }), [enabled, position, onDragStart, onDragEnd, onTap, containerBounds, elementSize, minPosition]);
390
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
391
+ style: style,
392
+ ...panResponder.panHandlers,
393
+ children: children
394
+ });
395
+ });
396
+
397
+ // ============================================================================
398
+ // MAIN COMPONENT - Optimized for 60FPS with transforms and interpolation
399
+ // ============================================================================
400
+ const BottomSheetComponent = ({
401
+ visible,
402
+ onClose,
403
+ children,
404
+ header,
405
+ styles: customStyles = {},
406
+ minHeight = MIN_HEIGHT,
407
+ maxHeight,
408
+ initialHeight = DEFAULT_HEIGHT,
409
+ initialMode = "bottomSheet",
410
+ onModeChange,
411
+ footer,
412
+ footerHeight = 0,
413
+ onBack,
414
+ theme: customTheme
415
+ }) => {
416
+ const theme = {
417
+ ...defaultTheme,
418
+ ...customTheme
419
+ };
420
+ const styles = (0, _react.useMemo)(() => createDynamicStyles(theme), [theme]);
421
+ const insets = useSafeAreaInsets();
422
+ const [mode, setMode] = (0, _react.useState)(initialMode);
423
+ const [isResizing, setIsResizing] = (0, _react.useState)(false);
424
+ const [isDragging, setIsDragging] = (0, _react.useState)(false);
425
+ const [panelHeight, setPanelHeight] = (0, _react.useState)(initialHeight);
426
+ const [dimensions, setDimensions] = (0, _react.useState)({
427
+ width: FLOATING_WIDTH,
428
+ height: FLOATING_HEIGHT,
429
+ top: (SCREEN.height - FLOATING_HEIGHT) / 2,
430
+ left: (SCREEN.width - FLOATING_WIDTH) / 2
431
+ });
432
+ const [containerBounds] = (0, _react.useState)({
433
+ width: SCREEN.width,
434
+ height: SCREEN.height
435
+ });
436
+
437
+ // ============================================================================
438
+ // ANIMATED VALUES - All using native driver
439
+ // ============================================================================
440
+ const visibilityProgress = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
441
+ const bottomSheetTranslateY = (0, _react.useRef)(new _reactNative.Animated.Value(SCREEN.height)).current;
442
+ const animatedBottomPosition = (0, _react.useRef)(new _reactNative.Animated.Value(initialHeight)).current;
443
+ const currentHeightRef = (0, _react.useRef)(initialHeight);
444
+
445
+ // Floating mode animations
446
+ const floatingPosition = (0, _react.useRef)(new _reactNative.Animated.ValueXY({
447
+ x: (SCREEN.width - FLOATING_WIDTH) / 2,
448
+ y: (SCREEN.height - FLOATING_HEIGHT) / 2
449
+ })).current;
450
+ const animatedWidth = (0, _react.useRef)(new _reactNative.Animated.Value(FLOATING_WIDTH)).current;
451
+ const animatedFloatingHeight = (0, _react.useRef)(new _reactNative.Animated.Value(FLOATING_HEIGHT)).current;
452
+
453
+ // Refs for resize handles
454
+ const currentDimensionsRef = (0, _react.useRef)(dimensions);
455
+ const startDimensionsRef = (0, _react.useRef)(dimensions);
456
+
457
+ // Update refs when dimensions change
458
+ (0, _react.useEffect)(() => {
459
+ currentDimensionsRef.current = dimensions;
460
+ }, [dimensions]);
461
+
462
+ // Cleanup on unmount
463
+ (0, _react.useEffect)(() => {
464
+ return () => {
465
+ visibilityProgress.stopAnimation();
466
+ bottomSheetTranslateY.stopAnimation();
467
+ animatedBottomPosition.stopAnimation();
468
+ floatingPosition.stopAnimation();
469
+ animatedWidth.stopAnimation();
470
+ animatedFloatingHeight.stopAnimation();
471
+ visibilityProgress.setValue(0);
472
+ bottomSheetTranslateY.setValue(SCREEN.height);
473
+ animatedBottomPosition.setValue(initialHeight);
474
+ currentHeightRef.current = initialHeight;
475
+ };
476
+ }, []);
477
+
478
+ // ============================================================================
479
+ // INTERPOLATIONS - All math done natively!
480
+ // ============================================================================
481
+ const modalOpacity = visibilityProgress.interpolate({
482
+ inputRange: [0, 1],
483
+ outputRange: [0, 1],
484
+ extrapolate: "clamp"
485
+ });
486
+ const effectiveMaxHeight = maxHeight || SCREEN.height - insets.top;
487
+
488
+ // Mode toggle handler
489
+ const toggleMode = (0, _react.useCallback)(() => {
490
+ setIsDragging(false);
491
+ setIsResizing(false);
492
+ const newMode = mode === "bottomSheet" ? "floating" : "bottomSheet";
493
+ setMode(newMode);
494
+ onModeChange?.(newMode);
495
+ }, [mode, onModeChange]);
496
+ (0, _react.useEffect)(() => {
497
+ setIsDragging(false);
498
+ setIsResizing(false);
499
+ }, [mode]);
500
+
501
+ // ============================================================================
502
+ // EFFECT: Visibility Animations - All using native driver!
503
+ // ============================================================================
504
+ (0, _react.useEffect)(() => {
505
+ let openAnimation = null;
506
+ let closeAnimation = null;
507
+ if (visible) {
508
+ bottomSheetTranslateY.setValue(SCREEN.height);
509
+ visibilityProgress.setValue(0);
510
+ if (mode === "bottomSheet") {
511
+ openAnimation = _reactNative.Animated.parallel([_reactNative.Animated.spring(bottomSheetTranslateY, {
512
+ toValue: 0,
513
+ tension: 180,
514
+ friction: 22,
515
+ useNativeDriver: true
516
+ }), _reactNative.Animated.timing(visibilityProgress, {
517
+ toValue: 1,
518
+ duration: 200,
519
+ useNativeDriver: true
520
+ })]);
521
+ openAnimation.start();
522
+ } else {
523
+ openAnimation = _reactNative.Animated.timing(visibilityProgress, {
524
+ toValue: 1,
525
+ duration: 200,
526
+ useNativeDriver: true
527
+ });
528
+ openAnimation.start();
529
+ }
530
+ } else {
531
+ if (mode === "bottomSheet") {
532
+ closeAnimation = _reactNative.Animated.parallel([_reactNative.Animated.spring(bottomSheetTranslateY, {
533
+ toValue: SCREEN.height,
534
+ tension: 180,
535
+ friction: 22,
536
+ useNativeDriver: true
537
+ }), _reactNative.Animated.timing(visibilityProgress, {
538
+ toValue: 0,
539
+ duration: 200,
540
+ useNativeDriver: true
541
+ })]);
542
+ closeAnimation.start();
543
+ } else {
544
+ closeAnimation = _reactNative.Animated.timing(visibilityProgress, {
545
+ toValue: 0,
546
+ duration: 200,
547
+ useNativeDriver: true
548
+ });
549
+ closeAnimation.start();
550
+ }
551
+ }
552
+ return () => {
553
+ if (openAnimation) openAnimation.stop();
554
+ if (closeAnimation) closeAnimation.stop();
555
+ };
556
+ }, [visible, mode, visibilityProgress, bottomSheetTranslateY]);
557
+
558
+ // ============================================================================
559
+ // OPTIMIZED PAN RESPONDER: Bottom Sheet Resize
560
+ // ============================================================================
561
+ const headerTouchOffsetRef = (0, _react.useRef)(0);
562
+ const bottomSheetPanResponder = (0, _react.useMemo)(() => _reactNative.PanResponder.create({
563
+ onStartShouldSetPanResponder: () => mode === "bottomSheet",
564
+ onMoveShouldSetPanResponder: (evt, gestureState) => mode === "bottomSheet" && Math.abs(gestureState.dy) > 3,
565
+ onPanResponderTerminationRequest: () => false,
566
+ onPanResponderGrant: evt => {
567
+ setIsResizing(true);
568
+ headerTouchOffsetRef.current = evt.nativeEvent.locationY || 0;
569
+ animatedBottomPosition.stopAnimation(val => {
570
+ currentHeightRef.current = val;
571
+ });
572
+ bottomSheetTranslateY.stopAnimation();
573
+ },
574
+ onPanResponderMove: evt => {
575
+ const sheetTop = evt.nativeEvent.pageY - headerTouchOffsetRef.current;
576
+ let targetHeight = SCREEN.height - sheetTop;
577
+ targetHeight = Math.max(minHeight, Math.min(targetHeight, effectiveMaxHeight));
578
+ animatedBottomPosition.setValue(targetHeight);
579
+ currentHeightRef.current = targetHeight;
580
+ },
581
+ onPanResponderRelease: (evt, gestureState) => {
582
+ setIsResizing(false);
583
+ const finalHeight = currentHeightRef.current;
584
+ const shouldClose = gestureState.vy > 0.8 && gestureState.dy > 50 || gestureState.dy > 150 && finalHeight <= minHeight;
585
+ if (shouldClose) {
586
+ _reactNative.Animated.parallel([_reactNative.Animated.timing(visibilityProgress, {
587
+ toValue: 0,
588
+ duration: 200,
589
+ useNativeDriver: true
590
+ }), _reactNative.Animated.spring(bottomSheetTranslateY, {
591
+ toValue: SCREEN.height,
592
+ tension: 180,
593
+ friction: 22,
594
+ useNativeDriver: true
595
+ })]).start(() => onClose());
596
+ return;
597
+ }
598
+ setPanelHeight(finalHeight);
599
+ },
600
+ onPanResponderTerminate: () => {
601
+ setIsResizing(false);
602
+ }
603
+ }), [mode, minHeight, effectiveMaxHeight, animatedBottomPosition, bottomSheetTranslateY, visibilityProgress, onClose]);
604
+
605
+ // ============================================================================
606
+ // CREATE RESIZE HANDLER: For 4-corner resize in floating mode
607
+ // ============================================================================
608
+ const createResizeHandler = (0, _react.useCallback)(corner => {
609
+ let didResize = false;
610
+ return _reactNative.PanResponder.create({
611
+ onStartShouldSetPanResponder: () => mode === "floating",
612
+ onMoveShouldSetPanResponder: () => mode === "floating",
613
+ onPanResponderGrant: () => {
614
+ didResize = false;
615
+ const currentDims = currentDimensionsRef.current;
616
+ floatingPosition.stopAnimation(({
617
+ x,
618
+ y
619
+ }) => {
620
+ floatingPosition.setValue({
621
+ x,
622
+ y
623
+ });
624
+ });
625
+ setIsResizing(true);
626
+ startDimensionsRef.current = {
627
+ ...currentDims
628
+ };
629
+ },
630
+ onPanResponderMove: (_evt, gestureState) => {
631
+ const {
632
+ dx,
633
+ dy
634
+ } = gestureState;
635
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
636
+ didResize = true;
637
+ const minLeft = Math.max(0, insets.left || 0);
638
+ const maxRight = containerBounds.width - Math.max(0, insets.right || 0);
639
+ const minTop = Math.max(0, insets.top || 0);
640
+ const maxBottom = containerBounds.height - Math.max(0, insets.bottom || 0);
641
+ const start = startDimensionsRef.current;
642
+ const startRight = start.left + start.width;
643
+ const startBottom = start.top + start.height;
644
+ let left = start.left;
645
+ let top = start.top;
646
+ let right = startRight;
647
+ let bottom = startBottom;
648
+ switch (corner) {
649
+ case "topLeft":
650
+ {
651
+ const newLeft = Math.max(minLeft, Math.min(start.left + dx, startRight - FLOATING_MIN_WIDTH));
652
+ const newTop = Math.max(minTop, Math.min(start.top + dy, startBottom - FLOATING_MIN_HEIGHT));
653
+ left = newLeft;
654
+ top = newTop;
655
+ right = startRight;
656
+ bottom = startBottom;
657
+ break;
658
+ }
659
+ case "topRight":
660
+ {
661
+ const newRight = Math.min(maxRight, Math.max(startRight + dx, start.left + FLOATING_MIN_WIDTH));
662
+ const newTop = Math.max(minTop, Math.min(start.top + dy, startBottom - FLOATING_MIN_HEIGHT));
663
+ left = start.left;
664
+ top = newTop;
665
+ right = newRight;
666
+ bottom = startBottom;
667
+ break;
668
+ }
669
+ case "bottomLeft":
670
+ {
671
+ const newLeft = Math.max(minLeft, Math.min(start.left + dx, startRight - FLOATING_MIN_WIDTH));
672
+ const newBottom = Math.min(maxBottom, Math.max(startBottom + dy, start.top + FLOATING_MIN_HEIGHT));
673
+ left = newLeft;
674
+ top = start.top;
675
+ right = startRight;
676
+ bottom = newBottom;
677
+ break;
678
+ }
679
+ case "bottomRight":
680
+ {
681
+ const newRight = Math.min(maxRight, Math.max(startRight + dx, start.left + FLOATING_MIN_WIDTH));
682
+ const newBottom = Math.min(maxBottom, Math.max(startBottom + dy, start.top + FLOATING_MIN_HEIGHT));
683
+ left = start.left;
684
+ top = start.top;
685
+ right = newRight;
686
+ bottom = newBottom;
687
+ break;
688
+ }
689
+ }
690
+ const updatedWidth = Math.max(FLOATING_MIN_WIDTH, right - left);
691
+ const updatedHeight = Math.max(FLOATING_MIN_HEIGHT, bottom - top);
692
+ setDimensions({
693
+ width: updatedWidth,
694
+ height: updatedHeight,
695
+ left,
696
+ top
697
+ });
698
+ animatedWidth.setValue(updatedWidth);
699
+ animatedFloatingHeight.setValue(updatedHeight);
700
+ floatingPosition.setValue({
701
+ x: left,
702
+ y: top
703
+ });
704
+ currentDimensionsRef.current = {
705
+ width: updatedWidth,
706
+ height: updatedHeight,
707
+ left,
708
+ top
709
+ };
710
+ },
711
+ onPanResponderRelease: () => {
712
+ setIsResizing(false);
713
+ if (corner === "topRight" && !didResize) {
714
+ onClose();
715
+ return;
716
+ }
717
+ if (corner === "topLeft" && !didResize && onBack) {
718
+ onBack();
719
+ return;
720
+ }
721
+ didResize = false;
722
+ setDimensions(currentDimensionsRef.current);
723
+ },
724
+ onPanResponderTerminate: () => {
725
+ setIsResizing(false);
726
+ didResize = false;
727
+ }
728
+ });
729
+ }, [mode, containerBounds, insets.left, insets.right, insets.top, insets.bottom, floatingPosition, animatedWidth, animatedFloatingHeight, onClose, onBack]);
730
+ const resizeHandlers = (0, _react.useMemo)(() => {
731
+ return {
732
+ topLeft: createResizeHandler("topLeft"),
733
+ topRight: createResizeHandler("topRight"),
734
+ bottomLeft: createResizeHandler("bottomLeft"),
735
+ bottomRight: createResizeHandler("bottomRight")
736
+ };
737
+ }, [createResizeHandler]);
738
+
739
+ // ============================================================================
740
+ // Floating Mode Drag Handlers
741
+ // ============================================================================
742
+ const handleFloatingDragStart = (0, _react.useCallback)(() => {
743
+ setIsDragging(true);
744
+ }, []);
745
+ const handleFloatingDragEnd = (0, _react.useCallback)(finalPosition => {
746
+ setIsDragging(false);
747
+ const currentDims = currentDimensionsRef.current;
748
+ const newDimensions = {
749
+ ...currentDims,
750
+ left: finalPosition.x,
751
+ top: finalPosition.y
752
+ };
753
+ setDimensions(newDimensions);
754
+ }, []);
755
+ const lastTapRef = (0, _react.useRef)(0);
756
+ const tapCountRef = (0, _react.useRef)(0);
757
+ const tapTimeoutRef = (0, _react.useRef)(null);
758
+ const handleFloatingTap = (0, _react.useCallback)(() => {
759
+ const now = Date.now();
760
+ const timeSinceLastTap = now - lastTapRef.current;
761
+ if (timeSinceLastTap > 500) {
762
+ tapCountRef.current = 0;
763
+ }
764
+ tapCountRef.current++;
765
+ lastTapRef.current = now;
766
+ if (tapTimeoutRef.current) {
767
+ clearTimeout(tapTimeoutRef.current);
768
+ }
769
+ tapTimeoutRef.current = setTimeout(() => {
770
+ if (tapCountRef.current === 2) {
771
+ toggleMode();
772
+ } else if (tapCountRef.current >= 3) {
773
+ onClose();
774
+ }
775
+ tapCountRef.current = 0;
776
+ }, 300);
777
+ }, [toggleMode, onClose]);
778
+ (0, _react.useEffect)(() => {
779
+ return () => {
780
+ if (tapTimeoutRef.current) {
781
+ clearTimeout(tapTimeoutRef.current);
782
+ }
783
+ };
784
+ }, []);
785
+
786
+ // ============================================================================
787
+ // RENDER: Modal UI with transform-based animations
788
+ // ============================================================================
789
+
790
+ if (!visible) {
791
+ return null;
792
+ }
793
+
794
+ // Render floating mode
795
+ if (mode === "floating") {
796
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Animated.View, {
797
+ style: [styles.floatingModal, {
798
+ width: dimensions.width,
799
+ height: dimensions.height,
800
+ opacity: modalOpacity,
801
+ transform: [{
802
+ translateX: floatingPosition.x
803
+ }, {
804
+ translateY: floatingPosition.y
805
+ }]
806
+ }, (isDragging || isResizing) && styles.floatingModalDragging, customStyles.container],
807
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(DraggableHeader, {
808
+ position: floatingPosition,
809
+ onDragStart: handleFloatingDragStart,
810
+ onDragEnd: handleFloatingDragEnd,
811
+ onTap: handleFloatingTap,
812
+ containerBounds: containerBounds,
813
+ elementSize: dimensions,
814
+ minPosition: {
815
+ x: 0,
816
+ y: insets.top
817
+ },
818
+ style: styles.floatingHeader,
819
+ enabled: mode === "floating" && !isResizing,
820
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(ModalHeader, {
821
+ header: header,
822
+ onClose: onClose,
823
+ onToggleMode: toggleMode,
824
+ isResizing: isDragging || isResizing,
825
+ mode: mode,
826
+ theme: theme
827
+ })
828
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
829
+ style: [styles.content, customStyles.content],
830
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
831
+ style: {
832
+ flex: 1
833
+ },
834
+ contentContainerStyle: {
835
+ flexGrow: 1,
836
+ paddingBottom: footerHeight
837
+ },
838
+ showsVerticalScrollIndicator: true,
839
+ nestedScrollEnabled: true,
840
+ children: children
841
+ }), footer ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
842
+ style: styles.footerContainer,
843
+ children: footer
844
+ }) : null]
845
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
846
+ ...resizeHandlers.topLeft.panHandlers,
847
+ style: [styles.cornerHandleWrapper, {
848
+ top: 4,
849
+ left: 4
850
+ }],
851
+ hitSlop: {
852
+ top: 8,
853
+ left: 8,
854
+ right: 8,
855
+ bottom: 8
856
+ },
857
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(CornerHandle, {
858
+ isActive: isDragging || isResizing,
859
+ theme: theme
860
+ })
861
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
862
+ ...resizeHandlers.topRight.panHandlers,
863
+ style: [styles.cornerHandleWrapper, {
864
+ top: 4,
865
+ right: 4
866
+ }],
867
+ hitSlop: {
868
+ top: 8,
869
+ left: 8,
870
+ right: 8,
871
+ bottom: 8
872
+ },
873
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(CornerHandle, {
874
+ isActive: isDragging || isResizing,
875
+ theme: theme
876
+ })
877
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
878
+ ...resizeHandlers.bottomLeft.panHandlers,
879
+ style: [styles.cornerHandleWrapper, {
880
+ bottom: 4,
881
+ left: 4
882
+ }],
883
+ hitSlop: {
884
+ top: 8,
885
+ left: 8,
886
+ right: 8,
887
+ bottom: 8
888
+ },
889
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(CornerHandle, {
890
+ isActive: isDragging || isResizing,
891
+ theme: theme
892
+ })
893
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
894
+ ...resizeHandlers.bottomRight.panHandlers,
895
+ style: [styles.cornerHandleWrapper, {
896
+ bottom: 4,
897
+ right: 4
898
+ }],
899
+ hitSlop: {
900
+ top: 8,
901
+ left: 8,
902
+ right: 8,
903
+ bottom: 8
904
+ },
905
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(CornerHandle, {
906
+ isActive: isDragging || isResizing,
907
+ theme: theme
908
+ })
909
+ })]
910
+ });
911
+ }
912
+
913
+ // Render bottom sheet mode
914
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
915
+ style: styles.fullScreenContainer,
916
+ pointerEvents: "box-none",
917
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Animated.View, {
918
+ style: [styles.bottomSheetWrapper, {
919
+ opacity: modalOpacity,
920
+ transform: [{
921
+ translateY: bottomSheetTranslateY
922
+ }]
923
+ }],
924
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Animated.View, {
925
+ style: [styles.bottomSheet, customStyles.container, {
926
+ height: animatedBottomPosition
927
+ }],
928
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(ModalHeader, {
929
+ header: header,
930
+ onClose: onClose,
931
+ onToggleMode: toggleMode,
932
+ isResizing: isResizing,
933
+ mode: mode,
934
+ panHandlers: bottomSheetPanResponder.panHandlers,
935
+ theme: theme
936
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
937
+ style: [styles.content, customStyles.content],
938
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
939
+ style: {
940
+ flex: 1
941
+ },
942
+ contentContainerStyle: {
943
+ flexGrow: 1,
944
+ paddingBottom: footerHeight
945
+ },
946
+ showsVerticalScrollIndicator: true,
947
+ nestedScrollEnabled: true,
948
+ children: children
949
+ }), footer ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
950
+ style: styles.footerContainer,
951
+ children: footer
952
+ }) : null]
953
+ })]
954
+ })
955
+ })
956
+ });
957
+ };
958
+
959
+ // ============================================================================
960
+ // DYNAMIC STYLES - Creates styles based on theme
961
+ // ============================================================================
962
+ const createDynamicStyles = theme => _reactNative.StyleSheet.create({
963
+ fullScreenContainer: {
964
+ ..._reactNative.StyleSheet.absoluteFillObject,
965
+ zIndex: 1000
966
+ },
967
+ bottomSheetWrapper: {
968
+ position: "absolute",
969
+ bottom: 0,
970
+ left: 0,
971
+ right: 0
972
+ },
973
+ bottomSheet: {
974
+ backgroundColor: theme.panel,
975
+ borderTopLeftRadius: 16,
976
+ borderTopRightRadius: 16,
977
+ borderWidth: 1,
978
+ borderColor: theme.border,
979
+ shadowColor: theme.info,
980
+ shadowOffset: {
981
+ width: 0,
982
+ height: -4
983
+ },
984
+ shadowOpacity: 0.3,
985
+ shadowRadius: 12,
986
+ elevation: 20
987
+ },
988
+ floatingModal: {
989
+ position: "absolute",
990
+ backgroundColor: theme.panel,
991
+ borderRadius: 16,
992
+ borderWidth: 1,
993
+ borderColor: theme.border,
994
+ shadowColor: theme.info,
995
+ shadowOffset: {
996
+ width: 0,
997
+ height: 0
998
+ },
999
+ shadowOpacity: 0.5,
1000
+ shadowRadius: 20,
1001
+ elevation: 24,
1002
+ zIndex: 1000,
1003
+ width: FLOATING_WIDTH,
1004
+ height: FLOATING_HEIGHT
1005
+ },
1006
+ floatingModalDragging: {
1007
+ borderColor: theme.success,
1008
+ borderWidth: 2,
1009
+ shadowColor: theme.success + "99",
1010
+ shadowOpacity: 0.8,
1011
+ shadowRadius: 12
1012
+ },
1013
+ header: {
1014
+ borderTopLeftRadius: 16,
1015
+ borderTopRightRadius: 16,
1016
+ backgroundColor: theme.panel,
1017
+ minHeight: 56,
1018
+ borderWidth: 1,
1019
+ borderColor: theme.border,
1020
+ borderBottomWidth: 1,
1021
+ borderBottomColor: "rgba(255, 255, 255, 0.1)"
1022
+ },
1023
+ floatingHeader: {
1024
+ borderTopLeftRadius: 14,
1025
+ borderTopRightRadius: 14
1026
+ },
1027
+ floatingModeHeader: {
1028
+ borderTopLeftRadius: 14,
1029
+ borderTopRightRadius: 14
1030
+ },
1031
+ headerInner: {
1032
+ flex: 1,
1033
+ justifyContent: "center"
1034
+ },
1035
+ dragIndicatorContainer: {
1036
+ alignItems: "center",
1037
+ paddingVertical: 8,
1038
+ backgroundColor: "transparent"
1039
+ },
1040
+ dragIndicatorContainerCustom: {
1041
+ paddingTop: 6,
1042
+ paddingBottom: 2,
1043
+ backgroundColor: "transparent"
1044
+ },
1045
+ dragIndicator: {
1046
+ width: 40,
1047
+ height: 3,
1048
+ backgroundColor: theme.info + "99",
1049
+ borderRadius: 2,
1050
+ shadowColor: theme.info,
1051
+ shadowOffset: {
1052
+ width: 0,
1053
+ height: 0
1054
+ },
1055
+ shadowOpacity: 0.8,
1056
+ shadowRadius: 4
1057
+ },
1058
+ floatingDragIndicator: {
1059
+ width: 50,
1060
+ height: 5,
1061
+ backgroundColor: theme.muted
1062
+ },
1063
+ dragIndicatorActive: {
1064
+ backgroundColor: theme.success,
1065
+ width: 40
1066
+ },
1067
+ resizeGripContainer: {
1068
+ position: "absolute",
1069
+ flexDirection: "row",
1070
+ gap: 2,
1071
+ marginTop: 12
1072
+ },
1073
+ resizeGripLine: {
1074
+ width: 12,
1075
+ height: 1,
1076
+ backgroundColor: theme.success,
1077
+ opacity: 0.6
1078
+ },
1079
+ headerContent: {
1080
+ paddingHorizontal: 16,
1081
+ alignItems: "center"
1082
+ },
1083
+ headerTitle: {
1084
+ fontSize: 16,
1085
+ fontWeight: "600",
1086
+ color: theme.primary
1087
+ },
1088
+ headerSubtitle: {
1089
+ fontSize: 12,
1090
+ color: theme.secondary,
1091
+ paddingTop: 4
1092
+ },
1093
+ headerHintText: {
1094
+ position: "absolute",
1095
+ top: 0,
1096
+ left: 0,
1097
+ right: 0,
1098
+ bottom: 0,
1099
+ justifyContent: "center",
1100
+ alignItems: "center"
1101
+ },
1102
+ hintText: {
1103
+ fontSize: 10,
1104
+ color: theme.muted,
1105
+ fontStyle: "italic"
1106
+ },
1107
+ content: {
1108
+ flex: 1,
1109
+ backgroundColor: theme.background,
1110
+ borderBottomLeftRadius: 16,
1111
+ borderBottomRightRadius: 16,
1112
+ overflow: "hidden"
1113
+ },
1114
+ cornerHandle: {
1115
+ position: "absolute",
1116
+ zIndex: 1
1117
+ },
1118
+ cornerHandleWrapper: {
1119
+ position: "absolute",
1120
+ width: 30,
1121
+ height: 30,
1122
+ zIndex: 1000
1123
+ },
1124
+ handler: {
1125
+ width: 20,
1126
+ height: 20,
1127
+ backgroundColor: "transparent",
1128
+ borderRadius: 10,
1129
+ borderWidth: 0,
1130
+ borderColor: "transparent"
1131
+ },
1132
+ handlerActive: {
1133
+ backgroundColor: theme.success + "1A",
1134
+ borderColor: theme.success,
1135
+ borderWidth: 2,
1136
+ shadowColor: theme.success + "99",
1137
+ shadowOffset: {
1138
+ width: 0,
1139
+ height: 0
1140
+ },
1141
+ shadowOpacity: 1,
1142
+ shadowRadius: 8
1143
+ },
1144
+ footerContainer: {
1145
+ position: "absolute",
1146
+ left: 0,
1147
+ right: 0,
1148
+ bottom: 0,
1149
+ backgroundColor: theme.background,
1150
+ borderBottomLeftRadius: 16,
1151
+ borderBottomRightRadius: 16
1152
+ }
1153
+ });
1154
+
1155
+ // ============================================================================
1156
+ // EXPORT - Memoized modal component for optimal performance
1157
+ // ============================================================================
1158
+ const BottomSheet = exports.BottomSheet = /*#__PURE__*/(0, _react.memo)(BottomSheetComponent);