UNPKG

@oxyhq/services

Version:

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

407 lines (395 loc) 14.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _react = _interopRequireWildcard(require("react")); var _reactNative = require("react-native"); var _reactNativeGestureHandler = require("react-native-gesture-handler"); var _reactNativeKeyboardController = require("react-native-keyboard-controller"); var _reactNativeReanimated = _interopRequireWildcard(require("react-native-reanimated")); var _reactNativeSafeAreaContext = require("react-native-safe-area-context"); var _useThemeColors = require("../hooks/useThemeColors.js"); var _jsxRuntime = require("react/jsx-runtime"); function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); } const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = _reactNative.Dimensions.get('window'); const SPRING_CONFIG = { damping: 25, stiffness: 300, mass: 0.8 }; const BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((props, ref) => { const { children, onDismiss, enablePanDownToClose = true, backgroundComponent, backdropComponent, style, enableHandlePanningGesture = true, onDismissAttempt, detached = false } = props; const insets = (0, _reactNativeSafeAreaContext.useSafeAreaInsets)(); const colors = (0, _useThemeColors.useThemeColors)(); const [visible, setVisible] = (0, _react.useState)(false); const [rendered, setRendered] = (0, _react.useState)(false); // keep mounted for exit animation const closeTimeoutRef = (0, _react.useRef)(null); const hasClosedRef = (0, _react.useRef)(false); const scrollViewRef = (0, _react.useRef)(null); const translateY = (0, _reactNativeReanimated.useSharedValue)(SCREEN_HEIGHT); const opacity = (0, _reactNativeReanimated.useSharedValue)(0); const scrollOffsetY = (0, _reactNativeReanimated.useSharedValue)(0); const isScrollAtTop = (0, _reactNativeReanimated.useSharedValue)(true); const allowPanClose = (0, _reactNativeReanimated.useSharedValue)(true); const keyboardHeight = (0, _reactNativeReanimated.useSharedValue)(0); const context = (0, _reactNativeReanimated.useSharedValue)({ y: 0 }); (0, _reactNativeKeyboardController.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 = (0, _react.useCallback)(() => { if (hasClosedRef.current) return; hasClosedRef.current = true; safeClose(); setRendered(false); }, [safeClose]); (0, _react.useEffect)(() => { if (visible) { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = null; } hasClosedRef.current = false; opacity.value = (0, _reactNativeReanimated.withTiming)(1, { duration: 250 }); translateY.value = (0, _reactNativeReanimated.withSpring)(0, SPRING_CONFIG); } else if (rendered) { opacity.value = (0, _reactNativeReanimated.withTiming)(0, { duration: 250 }, finished => { if (finished) { (0, _reactNativeReanimated.runOnJS)(finishClose)(); } }); translateY.value = (0, _reactNativeReanimated.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 (0, _react.useEffect)(() => () => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = null; } }, []); // Apply web scrollbar styles when colors change (0, _react.useEffect)(() => { if (_reactNative.Platform.OS === 'web') { createWebScrollbarStyle(colors.border); } }, [colors.border]); const present = (0, _react.useCallback)(() => { setRendered(true); setVisible(true); }, []); const dismiss = (0, _react.useCallback)(() => { setVisible(false); }, []); const scrollTo = (0, _react.useCallback)((y, animated = true) => { scrollViewRef.current?.scrollTo({ y, animated }); }, []); (0, _react.useImperativeHandle)(ref, () => ({ present, dismiss, close: dismiss, expand: present, collapse: dismiss, scrollTo }), [present, dismiss, scrollTo]); const nativeGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Native(), []); const panGesture = _reactNativeGestureHandler.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 = (0, _reactNativeReanimated.withSpring)(SCREEN_HEIGHT, { ...SPRING_CONFIG, velocity: velocity }); opacity.value = (0, _reactNativeReanimated.withTiming)(0, { duration: 250 }, finished => { if (finished) { (0, _reactNativeReanimated.runOnJS)(finishClose)(); } }); } else { translateY.value = (0, _reactNativeReanimated.withSpring)(0, { ...SPRING_CONFIG, velocity: velocity }); } }); const backdropStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({ opacity: opacity.value })); const sheetStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => { const scale = (0, _reactNativeReanimated.interpolate)(translateY.value, [0, SCREEN_HEIGHT], [1, 0.95]); return { transform: [{ translateY: translateY.value - keyboardHeight.value }, { scale }] }; }); const sheetHeightStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({ maxHeight: SCREEN_HEIGHT - keyboardHeight.value - insets.top - (detached ? insets.bottom + 16 : 0) }), [insets.top, insets.bottom, detached]); const sheetMarginStyle = (0, _reactNativeReanimated.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 = (0, _react.useCallback)(() => { // Always animate close on backdrop press if (onDismissAttempt && !onDismissAttempt()) { return; } dismiss(); }, [onDismissAttempt, dismiss]); const scrollHandler = (0, _reactNativeReanimated.useAnimatedScrollHandler)({ onScroll: event => { scrollOffsetY.value = event.contentOffset.y; isScrollAtTop.value = event.contentOffset.y <= 0; } }); const dynamicStyles = (0, _react.useMemo)(() => { const isDark = colors.background === '#000000'; return _reactNative.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__*/(0, _jsxRuntime.jsx)(_reactNative.Modal, { visible: rendered, transparent: true, animationType: "none", statusBarTranslucent: true, onRequestClose: dismiss, children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeGestureHandler.GestureHandlerRootView, { style: _reactNative.StyleSheet.absoluteFill, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, { style: [styles.backdrop, backdropStyle], children: backdropComponent ? backdropComponent({ onPress: handleBackdropPress }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, { style: styles.backdropTouchable, onPress: handleBackdropPress, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: _reactNative.StyleSheet.absoluteFill }) }) }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, { gesture: panGesture, children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeReanimated.default.View, { style: [dynamicStyles.sheet, sheetMarginStyle, sheetStyle, sheetHeightStyle], children: [backgroundComponent?.({ style: styles.background }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: dynamicStyles.handle }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, { gesture: nativeGesture, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.ScrollView, { ref: scrollViewRef, style: [styles.scrollView, _reactNative.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: _reactNative.Platform.OS === 'web' ? 'bottom-sheet-scrollview' : undefined, onLayout: () => { if (_reactNative.Platform.OS === 'web') { createWebScrollbarStyle(colors.border); } }, children: children }) })] }) })] }) }); }); BottomSheet.displayName = 'BottomSheet'; const styles = _reactNative.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: { ..._reactNative.StyleSheet.absoluteFillObject }, scrollView: { flex: 1 }, scrollContent: { flexGrow: 1 } }); // Create web scrollbar styles dynamically based on theme const createWebScrollbarStyle = borderColor => { if (_reactNative.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}; } `; }; var _default = exports.default = BottomSheet; //# sourceMappingURL=BottomSheet.js.map