diff --git a/App.tsx b/App.tsx
index 46e6dff..62bc986 100644
--- a/App.tsx
+++ b/App.tsx
@@ -8,7 +8,6 @@ import {ApolloProvider} from '@apollo/client';
import {useEffect, useState} from 'react';
import {
ActivityIndicator,
- Modal,
Pressable,
StatusBar,
StyleSheet,
@@ -30,6 +29,7 @@ import {
useAuthHydrated,
} from './src/auth/tokenStore';
import {MapScreen} from './src/map/MapScreen';
+import {Sheet} from './src/ui/Sheet';
function App() {
const isDarkMode = useColorScheme() === 'dark';
@@ -93,35 +93,26 @@ function AccountMenu({user}: {user: AuthUser}) {
]}>
{initial}
- setOpen(false)}>
- setOpen(false)}>
- true}>
-
-
- {user.email}
-
- {
- setOpen(false);
- logout();
- }}
- style={({pressed}) => [
- styles.sheetItem,
- pressed && styles.sheetItemPressed,
- ]}>
- Log out
-
-
+ onClose={() => setOpen(false)}
+ scrimAccessibilityLabel="Close account menu">
+
+ {user.email}
+
+ {
+ setOpen(false);
+ logout();
+ }}
+ style={({pressed}) => [
+ styles.sheetItem,
+ pressed && styles.sheetItemPressed,
+ ]}>
+ Log out
-
+
>
);
}
@@ -155,26 +146,6 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
- sheetBackdrop: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.35)',
- justifyContent: 'flex-end',
- },
- sheet: {
- backgroundColor: '#ffffff',
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- paddingTop: 8,
- paddingHorizontal: 8,
- },
- sheetHandle: {
- alignSelf: 'center',
- width: 36,
- height: 4,
- borderRadius: 2,
- backgroundColor: '#d0d0d0',
- marginBottom: 12,
- },
sheetEmail: {
fontSize: 12,
color: '#666',
diff --git a/src/map/ElementDetailModal.tsx b/src/map/ElementDetailModal.tsx
index 05742ae..b393d82 100644
--- a/src/map/ElementDetailModal.tsx
+++ b/src/map/ElementDetailModal.tsx
@@ -1,7 +1,6 @@
import {
ActivityIndicator,
Image,
- Modal,
Pressable,
ScrollView,
StyleSheet,
@@ -13,6 +12,7 @@ import {
type ElementDetailQuery,
useElementDetailQuery,
} from '../graphql/__generated__/types';
+import {Sheet} from '../ui/Sheet';
type Props = {
elementId: string | null;
@@ -28,17 +28,13 @@ export function ElementDetailModal({elementId, onClose}: Props) {
});
return (
-
+
-
+
);
}
diff --git a/src/ui/Sheet.tsx b/src/ui/Sheet.tsx
new file mode 100644
index 0000000..5b2cdfe
--- /dev/null
+++ b/src/ui/Sheet.tsx
@@ -0,0 +1,150 @@
+import {type ReactNode, useEffect, useRef, useState} from 'react';
+import {
+ Animated,
+ BackHandler,
+ Pressable,
+ StyleSheet,
+ useWindowDimensions,
+ View,
+} from 'react-native';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
+
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
+
+type SheetVariant = 'bottom' | 'fullscreen';
+
+type Props = {
+ visible: boolean;
+ onClose: () => void;
+ /** `bottom` (default): dim scrim + bottom-anchored card. `fullscreen`: opaque full-screen. */
+ variant?: SheetVariant;
+ /** Tap the scrim to close (bottom variant only). Defaults to true. */
+ dismissOnScrimPress?: boolean;
+ /** Accessibility label for the scrim's close affordance (bottom variant). */
+ scrimAccessibilityLabel?: string;
+ children: ReactNode;
+};
+
+/**
+ * In-tree modal sheet. Rendered as an absolute overlay (not a React Native
+ * `Modal`) so it inherits the app's edge-to-edge window and draws behind the
+ * status and navigation bars. RN's `Modal` renders in a separate window whose
+ * `statusBarTranslucent`/`navigationBarTranslucent` props don't reliably do
+ * this on RN 0.85 + Android 15, which left scrims and sheets stopping at the
+ * system bars.
+ *
+ * Owns the slide animation, hardware-back handling, and pointer-event gating;
+ * callers supply the content (and their own safe-area padding for fullscreen).
+ */
+export function Sheet({
+ visible,
+ onClose,
+ variant = 'bottom',
+ dismissOnScrimPress = true,
+ scrimAccessibilityLabel = 'Close',
+ children,
+}: Props) {
+ const safeAreaInsets = useSafeAreaInsets();
+ const {height} = useWindowDimensions();
+ const anim = useRef(new Animated.Value(0)).current;
+ const [sheetHeight, setSheetHeight] = useState(0);
+
+ useEffect(() => {
+ Animated.timing(anim, {
+ toValue: visible ? 1 : 0,
+ duration: variant === 'fullscreen' ? 250 : 220,
+ useNativeDriver: true,
+ }).start();
+ }, [visible, variant, anim]);
+
+ // The hardware back button closes the sheet (RN Modal used to handle this).
+ useEffect(() => {
+ if (!visible) {
+ return;
+ }
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
+ onClose();
+ return true;
+ });
+ return () => sub.remove();
+ }, [visible, onClose]);
+
+ if (variant === 'fullscreen') {
+ const translateY = anim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [height, 0],
+ });
+ return (
+
+ {children}
+
+ );
+ }
+
+ const translateY = anim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [sheetHeight || 320, 0],
+ });
+ return (
+
+
+
+ setSheetHeight(e.nativeEvent.layout.height)}
+ style={[styles.card, {paddingBottom: safeAreaInsets.bottom + 12}]}>
+
+ {children}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ fullscreen: {
+ backgroundColor: '#ffffff',
+ },
+ scrim: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0,0,0,0.35)',
+ },
+ sheetWrap: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ card: {
+ backgroundColor: '#ffffff',
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ paddingTop: 8,
+ paddingHorizontal: 8,
+ },
+ handle: {
+ alignSelf: 'center',
+ width: 36,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: '#d0d0d0',
+ marginBottom: 12,
+ },
+});