Files
orca/mobile/src/components/BottomDrawer.tsx
T
Brennan Benson e8831108ad fix(mobile): host Create Workspace drawers in one Modal so dropdowns open (#8642)
* fix(mobile): host Create Workspace drawers in one Modal so dropdowns open

The Create Workspace form and each of its pickers (repository, agent,
source, trust) rendered their own native Modal. Opening a dropdown
dismissed the form's Modal and presented the picker's Modal in the same
beat, but iOS cannot present one native modal while another is being
dismissed, so the incoming picker was dropped and the form collapsed —
the repository and agent dropdowns did nothing. On slower real devices
the race is lost every time; on the simulator it was intermittent.

Host the whole flow in a single persistent native Modal via a new
BottomDrawerModalHost + React context. When a BottomDrawer detects it is
inside the host it renders its sheet without its own Modal, so switching
from the form to a picker is an in-window view swap instead of a native
modal present/dismiss — no race. Every BottomDrawer outside this flow is
unchanged (the context defaults to off), and the picker wrappers need no
changes because the context propagates through them.

* fix(mobile): route native back through the setup-trust guard

The BottomDrawerModalHost onRequestClose sent every non-form view through
transitionDrawer('form'). For the setup-trust prompt that bypassed
closeSetupTrust(), whose guard keeps the prompt up while a trust decision
or workspace creation is in flight and which clears setupTrustPrompt.
Route drawerView === 'trust' back presses through closeSetupTrust() so
the guard and cleanup run.
2026-07-13 21:49:23 -07:00

441 lines
14 KiB
TypeScript

import { type ReactNode, useCallback, useEffect, useState } from 'react'
import {
View,
Pressable,
StyleSheet,
Platform,
useWindowDimensions,
ScrollView,
Keyboard,
BackHandler,
Modal
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedScrollHandler,
withSpring,
withTiming,
runOnJS,
interpolate,
Extrapolation
} from 'react-native-reanimated'
import { colors, spacing } from '../theme/mobile-theme'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
import { useResponsiveLayout } from '../layout/responsive-layout'
const DISMISS_THRESHOLD = 80
const SPRING_CONFIG = { damping: 28, stiffness: 400 }
// Why: negative translateY (pulling up) is damped with a rubber-band factor
// so the drawer resists upward dragging — a subtle polish touch that signals
// the drawer cannot expand further.
const RUBBER_BAND_FACTOR = 0.25
const SHOW_DURATION = 180
export const BOTTOM_DRAWER_HIDE_DURATION_MS = 150
const TOP_SCROLL_EPSILON = 1
type Props = {
visible: boolean
onClose: () => void
children: ReactNode
dragContentToDismiss?: boolean
contentScrollable?: boolean
zIndex?: number
}
export function BottomDrawer({
visible,
onClose,
children,
dragContentToDismiss = true,
contentScrollable = true,
zIndex
}: Props) {
const [mounted, setMounted] = useState(visible)
const resolvedMounted = resolveBottomDrawerMounted(visible, mounted)
// Why: opening drawers should mount before commit; waiting for a passive
// Effect adds a null render before every drawer can animate in.
if (resolvedMounted !== mounted) {
setMounted(resolvedMounted)
}
// Why: hidden drawers are rendered by parent screens even while closed; keep
// their Reanimated/Gesture setup out of hot paths like commit-message typing.
if (!resolvedMounted) {
return null
}
return (
<MountedBottomDrawer
visible={visible}
onClose={onClose}
onHidden={() => setMounted(false)}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
zIndex={zIndex}
>
{children}
</MountedBottomDrawer>
)
}
type MountedBottomDrawerProps = Props & {
onHidden: () => void
}
function MountedBottomDrawer({
visible,
onClose,
onHidden,
children,
dragContentToDismiss = true,
contentScrollable = true,
zIndex = 1000
}: MountedBottomDrawerProps) {
const translateY = useSharedValue(0)
const progress = useSharedValue(0)
const keyboardOffset = useSharedValue(0)
const scrollOffsetY = useSharedValue(0)
const contentDragStartY = useSharedValue(0)
const contentDragCanDismiss = useSharedValue(false)
const { height: screenHeight } = useWindowDimensions()
const insets = useSafeAreaInsets()
// Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
// transforms below) is unchanged, so phone behavior stays identical.
const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
const insideModalHost = useInsideBottomDrawerModalHost()
useEffect(() => {
if (visible) {
translateY.value = 0
scrollOffsetY.value = 0
progress.value = withTiming(1, { duration: SHOW_DURATION })
} else {
Keyboard.dismiss()
progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
if (finished) {
runOnJS(onHidden)()
}
})
}
}, [onHidden, visible])
// Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
// inside Modal (iOS ignores KAV; Android needs adjustNothing for
// useAnimatedKeyboard). Keyboard event listeners work on both platforms
// and give us the exact height to shift the drawer by.
useEffect(() => {
if (!visible) {
return
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const onShow = Keyboard.addListener(showEvent, (e) => {
const height = e.endCoordinates.height - insets.bottom
keyboardOffset.value = withTiming(Math.max(height, 0), { duration: e.duration || 250 })
})
const onHide = Keyboard.addListener(hideEvent, (e) => {
keyboardOffset.value = withTiming(0, { duration: e.duration || 250 })
})
return () => {
onShow.remove()
onHide.remove()
keyboardOffset.value = 0
}
}, [visible, insets.bottom])
const dismiss = useCallback(() => {
Keyboard.dismiss()
progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
if (finished) {
runOnJS(onClose)()
}
})
}, [onClose, progress])
useEffect(() => {
if (!visible) {
return
}
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
dismiss()
return true
})
return () => sub.remove()
}, [visible, dismiss])
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollOffsetY.value = Math.max(event.contentOffset.y, 0)
})
const scrollGesture = Gesture.Native()
const handlePanGesture = Gesture.Pan()
.activeOffsetY([-8, 8])
.simultaneousWithExternalGesture(scrollGesture)
.onUpdate((e) => {
if (e.translationY > 0) {
translateY.value = e.translationY
} else {
translateY.value = e.translationY * RUBBER_BAND_FACTOR
}
})
.onEnd((e) => {
if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
const velocity = Math.max(e.velocityY, 800)
const remaining = screenHeight - e.translationY
const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
translateY.value = withTiming(screenHeight, { duration })
progress.value = withTiming(0, { duration }, () => {
runOnJS(onClose)()
})
} else {
translateY.value = withSpring(0, SPRING_CONFIG)
}
})
const contentPanGesture = Gesture.Pan()
.activeOffsetY([-8, 8])
.simultaneousWithExternalGesture(scrollGesture)
.onBegin(() => {
contentDragStartY.value = 0
contentDragCanDismiss.value = scrollOffsetY.value <= TOP_SCROLL_EPSILON
})
.onUpdate((e) => {
// Why: action-sheet content can be taller than the drawer; downward drags
// should scroll back to the top before they start dismissing the sheet.
if (scrollOffsetY.value > TOP_SCROLL_EPSILON) {
contentDragCanDismiss.value = false
contentDragStartY.value = 0
if (translateY.value !== 0) {
translateY.value = withSpring(0, SPRING_CONFIG)
}
return
}
if (!contentDragCanDismiss.value) {
contentDragCanDismiss.value = true
contentDragStartY.value = e.translationY
}
const translationY = e.translationY - contentDragStartY.value
if (translationY > 0) {
translateY.value = translationY
} else {
translateY.value = translationY * RUBBER_BAND_FACTOR
}
})
.onEnd((e) => {
if (!contentDragCanDismiss.value || scrollOffsetY.value > TOP_SCROLL_EPSILON) {
return
}
const translationY = e.translationY - contentDragStartY.value
if (translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
const velocity = Math.max(e.velocityY, 800)
const remaining = screenHeight - translationY
const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
translateY.value = withTiming(screenHeight, { duration })
progress.value = withTiming(0, { duration }, () => {
runOnJS(onClose)()
})
} else {
translateY.value = withSpring(0, SPRING_CONFIG)
}
})
const drawerStyle = useAnimatedStyle(() => ({
transform: [
{
translateY:
interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) +
translateY.value -
keyboardOffset.value
}
]
}))
const backdropStyle = useAnimatedStyle(() => {
const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
return { opacity: progress.value * dragFade }
})
// Why: the sheet renders through a full-screen native window (its own Modal
// below, or the shared BottomDrawerModalHost) so it always covers the viewport
// — even when mounted deep inside a ScrollView, where a plain absolute overlay
// anchors to the scrolled content and clips the sheet. Show/hide is driven by
// `progress` (animationType "none") so the reanimated exit animation runs before
// the parent unmounts us.
const overlay = (
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
accessibilityViewIsModal
aria-modal
>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
drawerStyle
]}
>
{!contentScrollable ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<View style={styles.staticContent}>{children}</View>
</>
) : dragContentToDismiss ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
)
// Why: inside a BottomDrawerModalHost the host owns the single native Modal;
// rendering our own would stack modals and reintroduce the iOS present/dismiss
// race the host exists to avoid. The host handles the Android back button.
if (insideModalHost) {
return overlay
}
return (
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
{overlay}
</Modal>
)
}
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 1000
},
root: {
flex: 1
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)'
},
anchor: {
flex: 1,
justifyContent: 'flex-end'
},
anchorWide: {
alignItems: 'center'
},
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: spacing.md,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.2,
shadowRadius: 10
},
android: { elevation: 8 }
})
},
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
opacity: 0.4
},
handleHitArea: {
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.md
},
staticContent: {
minHeight: 0
},
bottomExtension: {
position: 'absolute',
bottom: -500,
left: 0,
right: 0,
height: 500,
backgroundColor: colors.bgBase
}
})