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