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 { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
@@ -898,23 +897,21 @@ export default function RootLayout() {
<SafeAreaProvider>
<KeyboardProvider>
<QueryProvider>
<BottomSheetModalProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</HostRuntimeBootstrapProvider>
</BottomSheetModalProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</HostRuntimeBootstrapProvider>
</QueryProvider>
</KeyboardProvider>
</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 { createPortal } from "react-dom";
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 { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
BottomSheetModal,
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
@@ -16,6 +15,10 @@ import {
import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb } from "@/constants/platform";
type EscHandler = () => void;
@@ -163,7 +166,6 @@ export interface AdaptiveModalSheetProps {
children: ReactNode;
headerActions?: ReactNode;
snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
@@ -180,7 +182,6 @@ export function AdaptiveModalSheet({
children,
headerActions,
snapPoints,
stackBehavior,
testID,
desktopMaxWidth,
onFilesDropped,
@@ -188,33 +189,13 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false);
const titleColor = theme.colors.foreground;
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
useEffect(() => {
if (!isMobile) return;
if (visible) {
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 { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile,
onClose,
});
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -230,7 +211,7 @@ export function AdaptiveModalSheet({
if (isMobile) {
return (
<BottomSheetModal
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={resolvedSnapPoints}
index={0}
@@ -238,7 +219,6 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
stackBehavior={stackBehavior}
backgroundComponent={SheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
keyboardBehavior="extend"
@@ -247,7 +227,7 @@ export function AdaptiveModalSheet({
>
<View style={styles.bottomSheetHeader} testID={testID}>
<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}
</Text>
{subtitle}
@@ -268,7 +248,7 @@ export function AdaptiveModalSheet({
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
)}
</BottomSheetModal>
</IsolatedBottomSheetModal>
);
}
@@ -276,7 +256,7 @@ export function AdaptiveModalSheet({
<>
<View style={styles.header}>
<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}
</Text>
{subtitle}
@@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetBackgroundProps,
@@ -30,6 +29,10 @@ import type {
import type { AgentProviderDefinition } 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 {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform";
@@ -256,31 +259,17 @@ export function DropdownSheet({
children,
}: DropdownSheetProps): ReactElement {
const { theme } = useUnistyles();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const titleColor = theme.colors.foreground;
const snapPoints = useMemo(() => ["60%", "90%"], []);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
onClose,
});
const handleClose = useCallback(() => {
bottomSheetRef.current?.dismiss();
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(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
@@ -289,8 +278,8 @@ export function DropdownSheet({
);
return (
<BottomSheetModal
ref={bottomSheetRef}
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
@@ -303,7 +292,9 @@ export function DropdownSheet({
keyboardBlurBehavior="restore"
>
<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
accessibilityRole="button"
accessibilityLabel="Close sheet"
@@ -321,7 +312,7 @@ export function DropdownSheet({
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
</IsolatedBottomSheetModal>
);
}
@@ -1229,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({
dropdownSheetTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
dropdownSheetScrollContent: {
@@ -630,7 +630,6 @@ function ControlledStatusBar({
title="Preferences"
visible={prefsOpen}
onClose={() => setPrefsOpen(false)}
stackBehavior="replace"
testID="agent-preferences-sheet"
>
{canSelectModel ? (
@@ -657,7 +657,6 @@ export function CombinedModelSelector({
onSelect={() => {}}
open={isOpen}
onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef}
desktopPlacement="top-start"
desktopMinWidth={360}
@@ -3,13 +3,16 @@ import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated from "react-native-reanimated";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
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 { ToolCallDetailsContent } from "./tool-call-details";
@@ -60,18 +63,25 @@ interface ToolCallSheetProviderProps {
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
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 snapPoints = useMemo(() => ["60%", "95%"], []);
const openToolCall = useCallback((data: ToolCallSheetData) => {
setSheetData(data);
if (hasPresentedRef.current) {
requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0));
return;
}
hasPresentedRef.current = true;
bottomSheetRef.current?.present();
}, []);
const closeToolCall = useCallback(() => {
bottomSheetRef.current?.dismiss();
bottomSheetRef.current?.close();
}, []);
const handleSheetChange = useCallback((index: number) => {
@@ -95,11 +105,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
return (
<ToolCallSheetContext.Provider value={contextValue}>
{children}
<BottomSheetModal
<IsolatedBottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
stackBehavior="replace"
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
@@ -108,7 +117,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
>
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
</BottomSheetModal>
</IsolatedBottomSheetModal>
</ToolCallSheetContext.Provider>
);
}
+15 -39
View File
@@ -14,7 +14,6 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetTextInput,
@@ -38,6 +37,10 @@ import {
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
const IS_WEB = isWeb;
@@ -66,8 +69,6 @@ export interface ComboboxProps {
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start";
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -271,8 +272,6 @@ export function Combobox({
title = "Select",
open,
onOpenChange,
enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
desktopMinWidth,
@@ -284,11 +283,10 @@ export function Combobox({
}: ComboboxProps): ReactElement {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null,
@@ -474,32 +472,11 @@ export function Combobox({
}
: floatingStyles;
useEffect(() => {
if (!isMobile) return;
if (isOpen) {
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) {
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 { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible: isOpen,
isEnabled: isMobile,
onClose: handleClose,
});
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -718,7 +695,7 @@ export function Combobox({
if (isMobile) {
return (
<BottomSheetModal
<IsolatedBottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
@@ -726,15 +703,15 @@ export function Combobox({
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<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>
{stickyHeader}
{!children && searchable ? searchInput : null}
@@ -745,7 +722,7 @@ export function Combobox({
>
{content}
</BottomSheetScrollView>
</BottomSheetModal>
</IsolatedBottomSheetModal>
);
}
@@ -931,7 +908,6 @@ const styles = StyleSheet.create((theme) => ({
comboboxTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left",
},
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 { useIsCompactFormFactor } from "@/constants/layout";
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 {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -354,7 +358,6 @@ export function ContextMenuContent({
const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context;
const bottomSheetRef = useRef<BottomSheetModal>(null);
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -364,23 +367,11 @@ export function ContextMenuContent({
setOpen(false);
}, [setOpen]);
useEffect(() => {
if (!useMobileSheet) return;
if (open) {
bottomSheetRef.current?.present();
return;
}
bottomSheetRef.current?.dismiss();
}, [open, useMobileSheet]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
}
},
[handleClose],
);
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible: open,
isEnabled: useMobileSheet,
onClose: handleClose,
});
const renderSheetBackdrop = useCallback(
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -468,7 +459,7 @@ export function ContextMenuContent({
if (useMobileSheet) {
return (
<ContextMenuContext.Provider value={context}>
<BottomSheetModal
<IsolatedBottomSheetModal
ref={bottomSheetRef}
index={0}
snapPoints={sheetSnapPoints}
@@ -495,7 +486,7 @@ export function ContextMenuContent({
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
</IsolatedBottomSheetModal>
</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"
open={isOpen}
onOpenChange={setIsOpen}
enableDismissOnClose={false}
anchorRef={anchorRef}
renderOption={({ option, selected, active, onPress }) => {
const tab = tabByKey.get(option.id);