fix(app): isolate bottom sheet modal scopes

This commit is contained in:
Mohamed Boudra
2026-04-20 19:02:31 +07:00
parent 2cd296e38f
commit d913c4a9b7
11 changed files with 329 additions and 147 deletions
+15 -18
View File
@@ -10,7 +10,6 @@ import {
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context"; import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
@@ -898,23 +897,21 @@ export default function RootLayout() {
<SafeAreaProvider> <SafeAreaProvider>
<KeyboardProvider> <KeyboardProvider>
<QueryProvider> <QueryProvider>
<BottomSheetModalProvider> <HostRuntimeBootstrapProvider>
<HostRuntimeBootstrapProvider> <PushNotificationRouter />
<PushNotificationRouter /> <ToastProvider>
<ToastProvider> <ProvidersWrapper>
<ProvidersWrapper> <SidebarAnimationProvider>
<SidebarAnimationProvider> <HorizontalScrollProvider>
<HorizontalScrollProvider> <OpenProjectListener />
<OpenProjectListener /> <AppWithSidebar>
<AppWithSidebar> <RootStack />
<RootStack /> </AppWithSidebar>
</AppWithSidebar> </HorizontalScrollProvider>
</HorizontalScrollProvider> </SidebarAnimationProvider>
</SidebarAnimationProvider> </ProvidersWrapper>
</ProvidersWrapper> </ToastProvider>
</ToastProvider> </HostRuntimeBootstrapProvider>
</HostRuntimeBootstrapProvider>
</BottomSheetModalProvider>
</QueryProvider> </QueryProvider>
</KeyboardProvider> </KeyboardProvider>
</SafeAreaProvider> </SafeAreaProvider>
@@ -1,4 +1,4 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useMemo } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
@@ -7,7 +7,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import { import {
BottomSheetModal,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetTextInput, BottomSheetTextInput,
@@ -16,6 +15,10 @@ import {
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
type EscHandler = () => void; type EscHandler = () => void;
@@ -163,7 +166,6 @@ export interface AdaptiveModalSheetProps {
children: ReactNode; children: ReactNode;
headerActions?: ReactNode; headerActions?: ReactNode;
snapPoints?: string[]; snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string; testID?: string;
/** Override the max width of the desktop card. */ /** Override the max width of the desktop card. */
desktopMaxWidth?: number; desktopMaxWidth?: number;
@@ -180,7 +182,6 @@ export function AdaptiveModalSheet({
children, children,
headerActions, headerActions,
snapPoints, snapPoints,
stackBehavior,
testID, testID,
desktopMaxWidth, desktopMaxWidth,
onFilesDropped, onFilesDropped,
@@ -188,33 +189,13 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const sheetRef = useRef<BottomSheetModal>(null); const titleColor = theme.colors.foreground;
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
useEffect(() => { visible,
if (!isMobile) return; isEnabled: isMobile,
if (visible) { onClose,
dismissingForVisibilityRef.current = false; });
sheetRef.current?.present();
} else {
dismissingForVisibilityRef.current = true;
sheetRef.current?.dismiss();
}
}, [visible, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
if (dismissingForVisibilityRef.current) {
dismissingForVisibilityRef.current = false;
return;
}
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -230,7 +211,7 @@ export function AdaptiveModalSheet({
if (isMobile) { if (isMobile) {
return ( return (
<BottomSheetModal <IsolatedBottomSheetModal
ref={sheetRef} ref={sheetRef}
snapPoints={resolvedSnapPoints} snapPoints={resolvedSnapPoints}
index={0} index={0}
@@ -238,7 +219,6 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
stackBehavior={stackBehavior}
backgroundComponent={SheetBackground} backgroundComponent={SheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }} handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
keyboardBehavior="extend" keyboardBehavior="extend"
@@ -247,7 +227,7 @@ export function AdaptiveModalSheet({
> >
<View style={styles.bottomSheetHeader} testID={testID}> <View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}> <View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}> <Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
{title} {title}
</Text> </Text>
{subtitle} {subtitle}
@@ -268,7 +248,7 @@ export function AdaptiveModalSheet({
) : ( ) : (
<View style={styles.bottomSheetStaticContent}>{children}</View> <View style={styles.bottomSheetStaticContent}>{children}</View>
)} )}
</BottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -276,7 +256,7 @@ export function AdaptiveModalSheet({
<> <>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerTitleGroup}> <View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}> <Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
{title} {title}
</Text> </Text>
{subtitle} {subtitle}
@@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
import type { StyleProp, ViewStyle, TextProps } from "react-native"; import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import {
BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
@@ -30,6 +29,10 @@ import type {
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest"; import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { baseColors } from "@/styles/theme"; import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
@@ -256,31 +259,17 @@ export function DropdownSheet({
children, children,
}: DropdownSheetProps): ReactElement { }: DropdownSheetProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const bottomSheetRef = useRef<BottomSheetModal>(null); const titleColor = theme.colors.foreground;
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ["60%", "90%"], []);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
onClose,
});
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
bottomSheetRef.current?.dismiss();
onClose(); onClose();
}, [onClose]); }, [onClose]);
useEffect(() => {
if (visible) {
bottomSheetRef.current?.present();
} else {
bottomSheetRef.current?.dismiss();
}
}, [visible]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} /> <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
@@ -289,8 +278,8 @@ export function DropdownSheet({
); );
return ( return (
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={sheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
@@ -303,7 +292,9 @@ export function DropdownSheet({
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
<View style={styles.bottomSheetHeader}> <View style={styles.bottomSheetHeader}>
<Text style={[styles.dropdownSheetTitle, { color: theme.colors.foreground }]}>{title}</Text> <Text key={titleColor} style={[styles.dropdownSheetTitle, { color: titleColor }]}>
{title}
</Text>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Close sheet" accessibilityLabel="Close sheet"
@@ -321,7 +312,7 @@ export function DropdownSheet({
> >
{children} {children}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -1229,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({
dropdownSheetTitle: { dropdownSheetTitle: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center", textAlign: "center",
}, },
dropdownSheetScrollContent: { dropdownSheetScrollContent: {
@@ -630,7 +630,6 @@ function ControlledStatusBar({
title="Preferences" title="Preferences"
visible={prefsOpen} visible={prefsOpen}
onClose={() => setPrefsOpen(false)} onClose={() => setPrefsOpen(false)}
stackBehavior="replace"
testID="agent-preferences-sheet" testID="agent-preferences-sheet"
> >
{canSelectModel ? ( {canSelectModel ? (
@@ -657,7 +657,6 @@ export function CombinedModelSelector({
onSelect={() => {}} onSelect={() => {}}
open={isOpen} open={isOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef} anchorRef={anchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
desktopMinWidth={360} desktopMinWidth={360}
@@ -3,13 +3,16 @@ import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { import {
BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
IsolatedBottomSheetModal,
type IsolatedBottomSheetModalRef,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
@@ -60,18 +63,25 @@ interface ToolCallSheetProviderProps {
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) { export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const hasPresentedRef = useRef(false);
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null); const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
const snapPoints = useMemo(() => ["60%", "95%"], []); const snapPoints = useMemo(() => ["60%", "95%"], []);
const openToolCall = useCallback((data: ToolCallSheetData) => { const openToolCall = useCallback((data: ToolCallSheetData) => {
setSheetData(data); setSheetData(data);
if (hasPresentedRef.current) {
requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0));
return;
}
hasPresentedRef.current = true;
bottomSheetRef.current?.present(); bottomSheetRef.current?.present();
}, []); }, []);
const closeToolCall = useCallback(() => { const closeToolCall = useCallback(() => {
bottomSheetRef.current?.dismiss(); bottomSheetRef.current?.close();
}, []); }, []);
const handleSheetChange = useCallback((index: number) => { const handleSheetChange = useCallback((index: number) => {
@@ -95,11 +105,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
return ( return (
<ToolCallSheetContext.Provider value={contextValue}> <ToolCallSheetContext.Provider value={contextValue}>
{children} {children}
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
stackBehavior="replace"
enableDynamicSizing={false} enableDynamicSizing={false}
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
@@ -108,7 +117,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }} handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
> >
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />} {sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
</BottomSheetModal> </IsolatedBottomSheetModal>
</ToolCallSheetContext.Provider> </ToolCallSheetContext.Provider>
); );
} }
+15 -39
View File
@@ -14,7 +14,6 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetTextInput, BottomSheetTextInput,
@@ -38,6 +37,10 @@ import {
} from "./combobox-options"; } from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
const IS_WEB = isWeb; const IS_WEB = isWeb;
@@ -66,8 +69,6 @@ export interface ComboboxProps {
title?: string; title?: string;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start"; desktopPlacement?: "top-start" | "bottom-start";
/** /**
* Prevents an initial frame at 0,0 by hiding desktop content until floating * Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -271,8 +272,6 @@ export function Combobox({
title = "Select", title = "Select",
open, open,
onOpenChange, onOpenChange,
enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start", desktopPlacement = "top-start",
desktopPreventInitialFlash = true, desktopPreventInitialFlash = true,
desktopMinWidth, desktopMinWidth,
@@ -284,11 +283,10 @@ export function Combobox({
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>( const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null, null,
@@ -474,32 +472,11 @@ export function Combobox({
} }
: floatingStyles; : floatingStyles;
useEffect(() => { const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
if (!isMobile) return; visible: isOpen,
if (isOpen) { isEnabled: isMobile,
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) { onClose: handleClose,
bottomSheetRef.current?.snapToIndex(0); });
} else {
hasPresentedBottomSheetRef.current = true;
bottomSheetRef.current?.present();
}
} else {
if (enableDismissOnClose === false) {
bottomSheetRef.current?.close();
} else {
bottomSheetRef.current?.dismiss();
}
}
}, [enableDismissOnClose, isOpen, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
}
},
[handleClose],
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -718,7 +695,7 @@ export function Combobox({
if (isMobile) { if (isMobile) {
return ( return (
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
@@ -726,15 +703,15 @@ export function Combobox({
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground} backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }} handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
<View style={styles.bottomSheetHeader}> <View style={styles.bottomSheetHeader}>
<Text style={[styles.comboboxTitle, { color: theme.colors.foreground }]}>{title}</Text> <Text key={titleColor} style={[styles.comboboxTitle, { color: titleColor }]}>
{title}
</Text>
</View> </View>
{stickyHeader} {stickyHeader}
{!children && searchable ? searchInput : null} {!children && searchable ? searchInput : null}
@@ -745,7 +722,7 @@ export function Combobox({
> >
{content} {content}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -931,7 +908,6 @@ const styles = StyleSheet.create((theme) => ({
comboboxTitle: { comboboxTitle: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left", textAlign: "left",
}, },
comboboxScrollContent: { comboboxScrollContent: {
+12 -21
View File
@@ -30,8 +30,12 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native"; import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -354,7 +358,6 @@ export function ContextMenuContent({
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet"; const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context; const { open, setOpen, triggerRef, anchorRect } = context;
const bottomSheetRef = useRef<BottomSheetModal>(null);
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []); const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null); const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -364,23 +367,11 @@ export function ContextMenuContent({
setOpen(false); setOpen(false);
}, [setOpen]); }, [setOpen]);
useEffect(() => { const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
if (!useMobileSheet) return; visible: open,
if (open) { isEnabled: useMobileSheet,
bottomSheetRef.current?.present(); onClose: handleClose,
return; });
}
bottomSheetRef.current?.dismiss();
}, [open, useMobileSheet]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
}
},
[handleClose],
);
const renderSheetBackdrop = useCallback( const renderSheetBackdrop = useCallback(
(props: ComponentProps<typeof BottomSheetBackdrop>) => ( (props: ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -468,7 +459,7 @@ export function ContextMenuContent({
if (useMobileSheet) { if (useMobileSheet) {
return ( return (
<ContextMenuContext.Provider value={context}> <ContextMenuContext.Provider value={context}>
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
index={0} index={0}
snapPoints={sheetSnapPoints} snapPoints={sheetSnapPoints}
@@ -495,7 +486,7 @@ export function ContextMenuContent({
> >
{children} {children}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </IsolatedBottomSheetModal>
</ContextMenuContext.Provider> </ContextMenuContext.Provider>
); );
} }
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
import { cleanup, render } from "@testing-library/react";
import React from "react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
const { modalMethods, modalProps } = vi.hoisted(() => ({
modalMethods: {
present: vi.fn(),
close: vi.fn(),
snapToIndex: vi.fn(),
dismiss: vi.fn(),
},
modalProps: vi.fn(),
}));
vi.mock("@gorhom/bottom-sheet", async () => {
const React = await import("react");
const MockBottomSheetModal = React.forwardRef(
(props: Record<string, unknown>, ref: React.ForwardedRef<unknown>) => {
modalProps(props);
React.useImperativeHandle(ref, () => modalMethods);
return React.createElement(
"div",
{ "data-testid": "bottom-sheet" },
props.children as ReactNode,
);
},
);
return {
BottomSheetModal: MockBottomSheetModal,
BottomSheetModalProvider: ({ children }: { children: ReactNode }) =>
React.createElement("div", { "data-testid": "bottom-sheet-provider" }, children),
};
});
vi.mock("@gorhom/portal", async () => {
const React = await import("react");
return {
Portal: ({ children, hostName }: { children: ReactNode; hostName?: string }) =>
React.createElement("div", { "data-host": hostName, "data-testid": "app-portal" }, children),
};
});
function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) {
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
onClose,
});
return (
<IsolatedBottomSheetModal
ref={sheetRef}
index={0}
snapPoints={["50%"]}
onChange={handleSheetChange}
>
<div>Sheet content</div>
</IsolatedBottomSheetModal>
);
}
describe("IsolatedBottomSheetModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("forces sheet isolation and keeps modal content mounted while hidden", () => {
const onClose = vi.fn();
const { getByTestId, rerender } = render(<Harness visible={false} onClose={onClose} />);
expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root");
expect(modalProps).toHaveBeenLastCalledWith(
expect.objectContaining({
enableDismissOnClose: false,
stackBehavior: "replace",
}),
);
expect(modalMethods.present).not.toHaveBeenCalled();
rerender(<Harness visible onClose={onClose} />);
expect(modalMethods.present).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />);
expect(modalMethods.close).toHaveBeenCalledTimes(1);
expect(modalMethods.dismiss).not.toHaveBeenCalled();
rerender(<Harness visible onClose={onClose} />);
expect(modalMethods.present).toHaveBeenCalledTimes(1);
expect(modalMethods.snapToIndex).toHaveBeenCalledWith(0);
});
it("only reports a user close when the sheet was visible", () => {
const onClose = vi.fn();
const { rerender } = render(<Harness visible onClose={onClose} />);
const latestProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
latestProps.onChange(-1);
expect(onClose).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />);
const closedProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
closedProps.onChange(-1);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("allows nested sheets inside a parent sheet without creating a sibling provider", () => {
const { getAllByTestId } = render(
<IsolatedBottomSheetModal index={0} snapPoints={["90%"]}>
<IsolatedBottomSheetModal index={0} snapPoints={["60%"]} onChange={() => {}}>
<div>Nested model picker</div>
</IsolatedBottomSheetModal>
</IsolatedBottomSheetModal>,
);
expect(getAllByTestId("bottom-sheet-provider")).toHaveLength(1);
expect(modalProps).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
stackBehavior: "replace",
}),
);
expect(modalProps).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
stackBehavior: "push",
}),
);
});
});
@@ -0,0 +1,106 @@
import {
BottomSheetModal as GorhomBottomSheetModal,
BottomSheetModalProvider,
type BottomSheetModalProps,
} from "@gorhom/bottom-sheet";
import { Portal } from "@gorhom/portal";
import React, { createContext, useContext } from "react";
import { forwardRef, useCallback, useEffect, useRef } from "react";
import type { ElementRef } from "react";
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior"
>;
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
const IsolatedBottomSheetScopeContext = createContext(false);
export const IsolatedBottomSheetModal = forwardRef<
IsolatedBottomSheetModalRef,
IsolatedBottomSheetModalProps
>(function IsolatedBottomSheetModal(props, ref) {
const isNestedSheet = useContext(IsolatedBottomSheetScopeContext);
const { children, ...bottomSheetProps } = props;
const scopedChildren =
typeof children === "function" ? (
(input: { data?: unknown }) => (
<IsolatedBottomSheetScopeContext.Provider value={true}>
{children(input) as React.ReactNode}
</IsolatedBottomSheetScopeContext.Provider>
)
) : (
<IsolatedBottomSheetScopeContext.Provider value={true}>
{children}
</IsolatedBottomSheetScopeContext.Provider>
);
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={ref}
enableDismissOnClose={false}
stackBehavior={isNestedSheet ? "push" : "replace"}
>
{scopedChildren}
</GorhomBottomSheetModal>
);
if (isNestedSheet) {
return modal;
}
return (
<Portal hostName="root">
<BottomSheetModalProvider>{modal}</BottomSheetModalProvider>
</Portal>
);
});
export function useIsolatedBottomSheetVisibility({
visible,
isEnabled,
onClose,
}: {
visible: boolean;
isEnabled?: boolean;
onClose: () => void;
}) {
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const hasPresentedRef = useRef(false);
useEffect(() => {
if (isEnabled === false) return;
if (visible) {
if (hasPresentedRef.current) {
sheetRef.current?.snapToIndex(0);
return;
}
hasPresentedRef.current = true;
sheetRef.current?.present();
return;
}
if (hasPresentedRef.current) {
sheetRef.current?.close();
}
}, [isEnabled, visible]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1 && visible) {
onClose();
}
},
[onClose, visible],
);
return {
sheetRef,
handleSheetChange,
};
}
@@ -471,7 +471,6 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
searchPlaceholder="Search tabs" searchPlaceholder="Search tabs"
open={isOpen} open={isOpen}
onOpenChange={setIsOpen} onOpenChange={setIsOpen}
enableDismissOnClose={false}
anchorRef={anchorRef} anchorRef={anchorRef}
renderOption={({ option, selected, active, onPress }) => { renderOption={({ option, selected, active, onPress }) => {
const tab = tabByKey.get(option.id); const tab = tabByKey.get(option.id);