UNPKG

@oxyhq/services

Version:

OxyHQ Expo/React Native SDK — UI components, screens, and native features

402 lines (390 loc) 12.3 kB
"use strict"; import React, { forwardRef, useImperativeHandle, useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { View, StyleSheet, Modal, Pressable, Dimensions, Platform } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; import { useKeyboardHandler } from 'react-native-keyboard-controller'; import Animated, { interpolate, runOnJS, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useThemeColors } from "../hooks/useThemeColors.js"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); const SPRING_CONFIG = { damping: 25, stiffness: 300, mass: 0.8 }; const BottomSheet = /*#__PURE__*/forwardRef((props, ref) => { const { children, onDismiss, enablePanDownToClose = true, backgroundComponent, backdropComponent, style, enableHandlePanningGesture = true, onDismissAttempt, detached = false } = props; const insets = useSafeAreaInsets(); const colors = useThemeColors(); const [visible, setVisible] = useState(false); const [rendered, setRendered] = useState(false); // keep mounted for exit animation const closeTimeoutRef = useRef(null); const hasClosedRef = useRef(false); const scrollViewRef = useRef(null); const translateY = useSharedValue(SCREEN_HEIGHT); const opacity = useSharedValue(0); const scrollOffsetY = useSharedValue(0); const isScrollAtTop = useSharedValue(true); const allowPanClose = useSharedValue(true); const keyboardHeight = useSharedValue(0); const context = useSharedValue({ y: 0 }); useKeyboardHandler({ onMove: e => { 'worklet'; keyboardHeight.value = e.height; }, onEnd: e => { 'worklet'; keyboardHeight.value = e.height; } }, []); // Dismiss callbacks const safeClose = () => { if (onDismissAttempt?.()) { onDismiss?.(); } else if (!onDismissAttempt) { onDismiss?.(); } }; const finishClose = useCallback(() => { if (hasClosedRef.current) return; hasClosedRef.current = true; safeClose(); setRendered(false); }, [safeClose]); useEffect(() => { if (visible) { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = null; } hasClosedRef.current = false; opacity.value = withTiming(1, { duration: 250 }); translateY.value = withSpring(0, SPRING_CONFIG); } else if (rendered) { opacity.value = withTiming(0, { duration: 250 }, finished => { if (finished) { runOnJS(finishClose)(); } }); translateY.value = withSpring(SCREEN_HEIGHT, { ...SPRING_CONFIG, stiffness: 250 }); // Fallback timer to ensure close completes (especially on web) if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); } closeTimeoutRef.current = setTimeout(() => { finishClose(); closeTimeoutRef.current = null; }, 300); } }, [visible, rendered, finishClose]); // Clear pending timeout on unmount useEffect(() => () => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = null; } }, []); // Apply web scrollbar styles when colors change useEffect(() => { if (Platform.OS === 'web') { createWebScrollbarStyle(colors.border); } }, [colors.border]); const present = useCallback(() => { setRendered(true); setVisible(true); }, []); const dismiss = useCallback(() => { setVisible(false); }, []); const scrollTo = useCallback((y, animated = true) => { scrollViewRef.current?.scrollTo({ y, animated }); }, []); useImperativeHandle(ref, () => ({ present, dismiss, close: dismiss, expand: present, collapse: dismiss, scrollTo }), [present, dismiss, scrollTo]); const nativeGesture = useMemo(() => Gesture.Native(), []); const panGesture = Gesture.Pan().enabled(enablePanDownToClose).simultaneousWithExternalGesture(nativeGesture).onStart(() => { 'worklet'; context.value = { y: translateY.value }; allowPanClose.value = scrollOffsetY.value <= 8; }).onUpdate(event => { 'worklet'; if (!allowPanClose.value) { return; } const newTranslateY = context.value.y + event.translationY; // If user is scrolling down while content isn't at (or near) the top, let ScrollView handle it const atTopOrNearTop = scrollOffsetY.value <= 8; // slightly larger tolerance for smoother handoff if (event.translationY > 0 && !atTopOrNearTop) { return; } if (newTranslateY >= 0) { translateY.value = newTranslateY; } else if (detached) { // Only allow overdrag (pulling up beyond top) when detached translateY.value = newTranslateY * 0.3; } else { // In normal mode, prevent overdrag - clamp to 0 translateY.value = 0; } }).onEnd(event => { 'worklet'; if (!allowPanClose.value) { return; } const velocity = event.velocityY; const distance = translateY.value; // Require a deeper pull to close (more like native bottom sheets) const closeThreshold = Math.max(140, SCREEN_HEIGHT * 0.25); const fastSwipeThreshold = 900; const shouldClose = velocity > fastSwipeThreshold || distance > closeThreshold && velocity > -300; if (shouldClose) { translateY.value = withSpring(SCREEN_HEIGHT, { ...SPRING_CONFIG, velocity: velocity }); opacity.value = withTiming(0, { duration: 250 }, finished => { if (finished) { runOnJS(finishClose)(); } }); } else { translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity: velocity }); } }); const backdropStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); const sheetStyle = useAnimatedStyle(() => { const scale = interpolate(translateY.value, [0, SCREEN_HEIGHT], [1, 0.95]); return { transform: [{ translateY: translateY.value - keyboardHeight.value }, { scale }] }; }); const sheetHeightStyle = useAnimatedStyle(() => ({ maxHeight: SCREEN_HEIGHT - keyboardHeight.value - insets.top - (detached ? insets.bottom + 16 : 0) }), [insets.top, insets.bottom, detached]); const sheetMarginStyle = useAnimatedStyle(() => { // Only add margin when detached, otherwise extend behind safe area if (detached) { return { marginBottom: keyboardHeight.value > 0 ? 16 : insets.bottom + 16 }; } return { marginBottom: 0 }; }, [insets.bottom, detached]); const handleBackdropPress = useCallback(() => { // Always animate close on backdrop press if (onDismissAttempt && !onDismissAttempt()) { return; } dismiss(); }, [onDismissAttempt, dismiss]); const scrollHandler = useAnimatedScrollHandler({ onScroll: event => { scrollOffsetY.value = event.contentOffset.y; isScrollAtTop.value = event.contentOffset.y <= 0; } }); const dynamicStyles = useMemo(() => { const isDark = colors.background === '#000000'; return StyleSheet.create({ handle: { ...styles.handle, backgroundColor: isDark ? '#444' : '#C7C7CC' }, sheet: { ...styles.sheet, backgroundColor: colors.background, ...(detached ? styles.sheetDetached : styles.sheetNormal) }, scrollContent: { ...styles.scrollContent // In normal mode, don't add padding here - screens handle their own padding // The sheet extends behind safe area, and screens add padding as needed } }); }, [colors.background, detached, insets.bottom]); if (!rendered) return null; return /*#__PURE__*/_jsx(Modal, { visible: rendered, transparent: true, animationType: "none", statusBarTranslucent: true, onRequestClose: dismiss, children: /*#__PURE__*/_jsxs(GestureHandlerRootView, { style: StyleSheet.absoluteFill, children: [/*#__PURE__*/_jsx(Animated.View, { style: [styles.backdrop, backdropStyle], children: backdropComponent ? backdropComponent({ onPress: handleBackdropPress }) : /*#__PURE__*/_jsx(Pressable, { style: styles.backdropTouchable, onPress: handleBackdropPress, children: /*#__PURE__*/_jsx(View, { style: StyleSheet.absoluteFill }) }) }), /*#__PURE__*/_jsx(GestureDetector, { gesture: panGesture, children: /*#__PURE__*/_jsxs(Animated.View, { style: [dynamicStyles.sheet, sheetMarginStyle, sheetStyle, sheetHeightStyle], children: [backgroundComponent?.({ style: styles.background }), /*#__PURE__*/_jsx(View, { style: dynamicStyles.handle }), /*#__PURE__*/_jsx(GestureDetector, { gesture: nativeGesture, children: /*#__PURE__*/_jsx(Animated.ScrollView, { ref: scrollViewRef, style: [styles.scrollView, Platform.OS === 'web' && { scrollbarWidth: 'thin', scrollbarColor: `${colors.border} transparent` }], contentContainerStyle: dynamicStyles.scrollContent, showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", onScroll: scrollHandler, scrollEventThrottle: 16 // @ts-ignore - Web className , className: Platform.OS === 'web' ? 'bottom-sheet-scrollview' : undefined, onLayout: () => { if (Platform.OS === 'web') { createWebScrollbarStyle(colors.border); } }, children: children }) })] }) })] }) }); }); BottomSheet.displayName = 'BottomSheet'; const styles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)' }, backdropTouchable: { flex: 1 }, sheet: { position: 'absolute', bottom: 0, overflow: 'hidden', maxWidth: 800, alignSelf: 'center', marginHorizontal: 'auto' }, sheetDetached: { left: 16, right: 16, borderRadius: 24 }, sheetNormal: { left: 0, right: 0, borderTopLeftRadius: 24, borderTopRightRadius: 24 }, handle: { position: 'absolute', top: 10, left: '50%', marginLeft: -18, width: 36, height: 5, borderRadius: 3, zIndex: 100 }, background: { ...StyleSheet.absoluteFillObject }, scrollView: { flex: 1 }, scrollContent: { flexGrow: 1 } }); // Create web scrollbar styles dynamically based on theme const createWebScrollbarStyle = borderColor => { if (Platform.OS !== 'web' || typeof document === 'undefined') return; const styleId = 'bottom-sheet-scrollbar-style'; let styleElement = document.getElementById(styleId); if (!styleElement) { styleElement = document.createElement('style'); styleElement.id = styleId; document.head.appendChild(styleElement); } // Use theme border color for scrollbar const scrollbarColor = borderColor; const scrollbarHoverColor = borderColor === '#E5E5EA' ? '#C7C7CC' : '#555'; styleElement.textContent = ` .bottom-sheet-scrollview::-webkit-scrollbar { width: 6px; } .bottom-sheet-scrollview::-webkit-scrollbar-track { background: transparent; border-radius: 10px; } .bottom-sheet-scrollview::-webkit-scrollbar-thumb { background: ${scrollbarColor}; border-radius: 10px; } .bottom-sheet-scrollview::-webkit-scrollbar-thumb:hover { background: ${scrollbarHoverColor}; } `; }; export default BottomSheet; //# sourceMappingURL=BottomSheet.js.map