@oxyhq/services
Version:
402 lines (390 loc) • 12.3 kB
JavaScript
"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