Files
llink/js/mobile/src/components/BottomSheet.tsx
T
Arjun PatelandGitHub a8a0b7db1b infra: add linting and formatting for js projects (#230)
* wip

* wip

* wip

* format

* wip(mobile): lint and format

* cleanup

* nits

* nits

* idiomatic react

* nit

* format all root files
2026-06-02 07:44:24 -07:00

201 lines
5.8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import {
Dimensions,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
View,
} from 'react-native';
import {
initialWindowMetrics,
SafeAreaProvider,
SafeAreaView,
} from 'react-native-safe-area-context';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Easing,
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
const SCREEN_HEIGHT = Dimensions.get('window').height;
const ANIMATION_MS = 240;
interface BottomSheetProps {
open: boolean;
onClose: () => void;
/**
* Fires after the close animation completes and the underlying Modal has
* unmounted. Use this to chain a follow-up sheet/Alert without stacking
* iOS Modals — presenting a second Modal while another is still mounted
* silently fails on iOS and leaves the app looking frozen.
*/
onClosed?: () => void;
/** When true, wrap content in KeyboardAvoidingView so the sheet floats above the keyboard. */
avoidKeyboard?: boolean;
/**
* Cap on the sheet's height. Defaults to 85%; pass a string like "60%" or
* a number of px when content has a stable footprint.
*/
maxHeight?: number | `${number}%`;
children: React.ReactNode;
}
/**
* Shared modal sheet shell. Handles slide-in animation, backdrop fade,
* drag-to-dismiss, and modal-safe SafeAreaProvider seeding so iOS modals get
* correct insets on the first frame. The drag handle at the top is rendered
* here too, so callers don't need to draw it themselves.
*/
export function BottomSheet({
open,
onClose,
onClosed,
avoidKeyboard = false,
maxHeight = '85%',
children,
}: BottomSheetProps) {
// Mount slightly past `open` so the slide-in animation has its starting
// position rendered, and the slide-out animation can play before unmount.
const [mounted, setMounted] = useState(false);
const translateY = useSharedValue(SCREEN_HEIGHT);
// Mount as soon as we open; the close path unmounts after the exit animation.
if (open && !mounted) setMounted(true);
// Latest onClosed in a ref so the worklet→JS bridge always invokes the
// current callback even if the parent re-rendered with a new closure.
const onClosedRef = useRef(onClosed);
useEffect(() => {
onClosedRef.current = onClosed;
}, [onClosed]);
const handleClosed = useCallback(() => {
setMounted(false);
onClosedRef.current?.();
}, []);
useEffect(() => {
if (open) {
requestAnimationFrame(() => {
translateY.value = withSpring(0, {
damping: 24,
stiffness: 260,
mass: 0.7,
});
});
} else if (mounted) {
translateY.value = withTiming(
SCREEN_HEIGHT,
{ duration: ANIMATION_MS, easing: Easing.in(Easing.cubic) },
(finished) => {
if (finished) runOnJS(handleClosed)();
},
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const sheetPan = Gesture.Pan()
.activeOffsetY(10)
.failOffsetX([-25, 25])
.onUpdate((e) => {
'worklet';
// Reanimated shared values are mutated by design; react-hooks/immutability
// doesn't model worklets, so the mutations below are flagged spuriously.
// eslint-disable-next-line react-hooks/immutability
translateY.value = Math.max(0, e.translationY);
})
.onEnd((e) => {
'worklet';
if (e.translationY > 120 || e.velocityY > 800) {
runOnJS(onClose)();
} else {
// eslint-disable-next-line react-hooks/immutability
translateY.value = withSpring(0, {
damping: 24,
stiffness: 260,
mass: 0.7,
});
}
});
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
const backdropStyle = useAnimatedStyle(() => {
const opacity = interpolate(
translateY.value,
[0, SCREEN_HEIGHT * 0.7],
[0.55, 0],
Extrapolation.CLAMP,
);
return { opacity };
});
if (!mounted) return null;
const Wrapper = avoidKeyboard ? KeyboardAvoidingView : View;
const wrapperProps = avoidKeyboard
? { behavior: Platform.OS === 'ios' ? ('padding' as const) : undefined }
: {};
return (
<Modal
visible={mounted}
transparent
animationType="none"
onRequestClose={onClose}
>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<View style={{ flex: 1 }}>
<Animated.View
pointerEvents={open ? 'auto' : 'none'}
style={[
{ position: 'absolute', inset: 0, backgroundColor: 'black' },
backdropStyle,
]}
>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Wrapper
{...wrapperProps}
style={{ flex: 1, justifyContent: 'flex-end' }}
pointerEvents="box-none"
>
<GestureDetector gesture={sheetPan}>
<Animated.View
style={[
{
backgroundColor: '#1c1c1c',
borderTopLeftRadius: 22,
borderTopRightRadius: 22,
overflow: 'hidden',
maxHeight,
},
sheetStyle,
]}
>
<SafeAreaView edges={['bottom']}>
<View className="px-5 pt-3 items-center">
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
</View>
{children}
</SafeAreaView>
</Animated.View>
</GestureDetector>
</Wrapper>
</View>
</SafeAreaProvider>
</Modal>
);
}