@oxyhq/services
Version:
407 lines (395 loc) • 14.3 kB
JavaScript
"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