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, + }, +});