Fix stacked provider settings sheets

This commit is contained in:
Mohamed Boudra
2026-06-05 21:15:44 +07:00
parent abf129f56e
commit dd329a4f52
13 changed files with 242 additions and 62 deletions
+5
View File
@@ -169,6 +169,11 @@ on that attach races Gorhom's dismiss path and leaves the modal unable to reopen
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
ignore ref churn while dismissing. ignore ref churn while dismissing.
Do not treat `onChange(-1)` as a close by itself. In a stacked
`BottomSheetModal`, `-1` can also mean the sheet is temporarily hidden under
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
## Recipe for a new anchored panel ## Recipe for a new anchored panel
Before you write a new one, ask: Before you write a new one, ask:
@@ -0,0 +1,109 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgentAtMobileBreakpoint(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-sheet-stack-",
title: "Provider sheet stack e2e",
initialPrompt: "Prepare provider sheet stack test agent.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
timeout: 30_000,
});
return session;
}
async function openProviderSettingsFromModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
await expect(page.getByLabel("Bottom Sheet", { exact: true })).toBeVisible({ timeout: 10_000 });
const openCodeRow = page.getByText("OpenCode", { exact: true }).first();
if (await openCodeRow.isVisible().catch(() => false)) {
await openCodeRow.click();
}
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
}
async function expectModelSelectorVisible(page: Page) {
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible({
timeout: 10_000,
});
}
async function closeTopSheet(page: Page) {
const closeTarget = page.getByLabel("Close", { exact: true }).last();
if (await closeTarget.isVisible().catch(() => false)) {
await closeTarget.click({ force: true });
return;
}
const handle = page.getByRole("slider", { name: "Bottom sheet handle" }).last();
const handleBox = await handle.boundingBox();
if (!handleBox) {
throw new Error("Bottom sheet handle was not measurable");
}
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 400, { steps: 8 });
await page.mouse.up();
}
async function expectProviderSettingsVisible(page: Page) {
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
await expect(page.getByRole("button", { name: "Diagnostic", exact: true })).toBeVisible();
}
async function exerciseProviderSettingsStack(page: Page) {
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Add model" }).click();
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expectProviderSettingsVisible(page);
}
test.describe("provider settings bottom-sheet stack", () => {
test("provider settings and children close back through the model selector stack", async ({
page,
}) => {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await openProviderSettingsFromModelSelector(page);
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await expectModelSelectorVisible(page);
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await expectModelSelectorVisible(page);
await closeTopSheet(page);
} finally {
await session.cleanup();
}
});
});
+7 -3
View File
@@ -1,4 +1,5 @@
import "@/styles/unistyles"; import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
@@ -931,17 +932,20 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
); );
} }
// PortalProvider must remain the innermost global provider here. // PortalProvider must stay inside normal app-wide context providers here.
// `@gorhom/portal` renders portaled children at the host's location in the // `@gorhom/portal` renders portaled children at the host's location in the
// tree, so any context a portaled sheet might consume (QueryClient, theme, // tree, so any context a portaled sheet might consume (QueryClient, theme,
// auth, settings, …) must wrap PortalProvider — not be wrapped by it. // auth, settings, …) must wrap PortalProvider — not be wrapped by it.
// Adding a new global provider? Put it above PortalProvider. // BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootProviders({ children }: { children: ReactNode }) { function RootProviders({ children }: { children: ReactNode }) {
return ( return (
<QueryProvider> <QueryProvider>
<SafeAreaProvider> <SafeAreaProvider>
<KeyboardProvider> <KeyboardProvider>
<PortalProvider>{children}</PortalProvider> <PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardProvider> </KeyboardProvider>
</SafeAreaProvider> </SafeAreaProvider>
</QueryProvider> </QueryProvider>
@@ -12,6 +12,7 @@ import {
BottomSheetTextInput, BottomSheetTextInput,
type BottomSheetBackgroundProps, type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native"; import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/composer/types"; import type { ImageAttachment } from "@/composer/types";
@@ -232,14 +233,14 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
() => [ () => [
style, style,
{ {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius.xl, borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius.xl, borderTopRightRadius: theme.borderRadius["2xl"],
}, },
], ],
[style, theme.colors.surface1, theme.borderRadius.xl], [style, theme.colors.surface0, theme.borderRadius],
); );
return <View style={combinedStyle} />; return <Animated.View pointerEvents="none" style={combinedStyle} />;
} }
export type AdaptiveTextInputProps = TextInputProps & { export type AdaptiveTextInputProps = TextInputProps & {
@@ -444,6 +445,7 @@ export interface AdaptiveModalSheetProps {
/** When provided, wraps the card content in a FileDropZone. */ /** When provided, wraps the card content in a FileDropZone. */
onFilesDropped?: (files: ImageAttachment[]) => void; onFilesDropped?: (files: ImageAttachment[]) => void;
scrollable?: boolean; scrollable?: boolean;
presentation?: "push" | "replace";
} }
export function AdaptiveModalSheet({ export function AdaptiveModalSheet({
@@ -457,6 +459,7 @@ export function AdaptiveModalSheet({
desktopMaxWidth, desktopMaxWidth,
onFilesDropped, onFilesDropped,
scrollable = true, scrollable = true,
presentation,
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
@@ -501,8 +504,8 @@ export function AdaptiveModalSheet({
[compactSafeAreaPadding.footerPaddingBottom], [compactSafeAreaPadding.footerPaddingBottom],
); );
const handleIndicatorStyle = useMemo( const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }), () => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.surface2], [theme.colors.palette.zinc],
); );
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible, visible,
@@ -543,6 +546,7 @@ export function AdaptiveModalSheet({
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
accessible={false} accessible={false}
presentation={presentation}
> >
<SheetHeaderView header={header} onClose={onClose} testID={testID} /> <SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? ( {scrollable ? (
@@ -685,10 +685,6 @@ export function CombinedModelSelector({
handleOpenChange(!isOpen); handleOpenChange(!isOpen);
}, [handleOpenChange, isOpen]); }, [handleOpenChange, isOpen]);
const handleClose = useCallback(() => {
handleOpenChange(false);
}, [handleOpenChange]);
const triggerStyle = useCallback( const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger, styles.trigger,
@@ -714,11 +710,10 @@ export function CombinedModelSelector({
setSearchQuery(value); setSearchQuery(value);
}, []); }, []);
const openHeaderProviderSettings = useCallback(() => { const openProviderSettings = useCallback(() => {
if (!serverId || view.kind !== "provider") return; if (!serverId || view.kind !== "provider") return;
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId }); useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
handleClose(); }, [serverId, view]);
}, [serverId, view, handleClose]);
const sheetHeader = useMemo<SheetHeader>(() => { const sheetHeader = useMemo<SheetHeader>(() => {
if (view.kind === "all") { if (view.kind === "all") {
@@ -727,7 +722,7 @@ export function CombinedModelSelector({
const ProviderIconForView = getProviderIcon(view.providerId); const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = ( const headerActions = (
<Pressable <Pressable
onPress={openHeaderProviderSettings} onPress={openProviderSettings}
disabled={!serverId} disabled={!serverId}
hitSlop={8} hitSlop={8}
style={iconButtonStyle} style={iconButtonStyle}
@@ -760,7 +755,7 @@ export function CombinedModelSelector({
view, view,
singleProviderView, singleProviderView,
serverId, serverId,
openHeaderProviderSettings, openProviderSettings,
theme.colors.border, theme.colors.border,
theme.colors.foregroundMuted, theme.colors.foregroundMuted,
handleBackToAll, handleBackToAll,
@@ -196,6 +196,7 @@ function AddCustomModelSubSheet({
onClose={onClose} onClose={onClose}
desktopMaxWidth={420} desktopMaxWidth={420}
snapPoints={ADD_SNAP_POINTS} snapPoints={ADD_SNAP_POINTS}
testID="add-custom-model-sheet"
> >
<View style={sheetStyles.formGroup}> <View style={sheetStyles.formGroup}>
<Text style={sheetStyles.formLabel}>Model ID</Text> <Text style={sheetStyles.formLabel}>Model ID</Text>
@@ -339,6 +340,7 @@ function DiagnosticSubSheet({
onClose={onClose} onClose={onClose}
snapPoints={DIAGNOSTIC_SNAP_POINTS} snapPoints={DIAGNOSTIC_SNAP_POINTS}
scrollable={false} scrollable={false}
testID="provider-diagnostic-sheet"
> >
<View style={DIAGNOSTIC_CARD_STYLE}>{body}</View> <View style={DIAGNOSTIC_CARD_STYLE}>{body}</View>
</AdaptiveModalSheet> </AdaptiveModalSheet>
@@ -616,6 +618,7 @@ export function ProviderDiagnosticSheet({
header={sheetHeader} header={sheetHeader}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
testID="provider-settings-sheet"
footer={renderProviderSheetFooter({ footer={renderProviderSheetFooter({
fetchedAtLabel, fetchedAtLabel,
isCompact, isCompact,
@@ -5,6 +5,7 @@ import { useProviderSettingsStore } from "@/stores/provider-settings-store";
export function ProviderSettingsHost() { export function ProviderSettingsHost() {
const serverId = useProviderSettingsStore((state) => state.serverId); const serverId = useProviderSettingsStore((state) => state.serverId);
const provider = useProviderSettingsStore((state) => state.provider); const provider = useProviderSettingsStore((state) => state.provider);
const visible = useProviderSettingsStore((state) => state.visible);
const close = useProviderSettingsStore((state) => state.close); const close = useProviderSettingsStore((state) => state.close);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -19,7 +20,7 @@ export function ProviderSettingsHost() {
<ProviderDiagnosticSheet <ProviderDiagnosticSheet
provider={provider} provider={provider}
serverId={serverId} serverId={serverId}
visible visible={visible}
onClose={handleClose} onClose={handleClose}
/> />
); );
@@ -95,6 +95,7 @@ export interface ComboboxProps {
*/ */
header?: SheetHeader; header?: SheetHeader;
mobileChildrenScrollEnabled?: boolean; mobileChildrenScrollEnabled?: boolean;
presentation?: "push" | "replace";
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start"; desktopPlacement?: "top-start" | "bottom-start";
@@ -965,6 +966,7 @@ interface MobileBodyProps {
searchable: boolean; searchable: boolean;
hasChildren: boolean; hasChildren: boolean;
mobileChildrenScrollEnabled: boolean; mobileChildrenScrollEnabled: boolean;
presentation?: "push" | "replace";
searchResetKey: number; searchResetKey: number;
searchPlaceholder: string; searchPlaceholder: string;
searchQuery: string; searchQuery: string;
@@ -1024,6 +1026,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
handleIndicatorStyle={props.handleIndicatorStyle} handleIndicatorStyle={props.handleIndicatorStyle}
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="none" keyboardBlurBehavior="none"
presentation={props.presentation}
> >
{props.header ? ( {props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} /> <SheetHeaderView header={props.header} onClose={props.onClose} />
@@ -1248,6 +1251,7 @@ export function Combobox({
title = "Select", title = "Select",
header, header,
mobileChildrenScrollEnabled = true, mobileChildrenScrollEnabled = true,
presentation,
open, open,
onOpenChange, onOpenChange,
desktopPlacement = "top-start", desktopPlacement = "top-start",
@@ -1534,6 +1538,7 @@ export function Combobox({
searchable={searchable} searchable={searchable}
hasChildren={hasChildren} hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled} mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
presentation={presentation}
searchResetKey={searchResetKey} searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder} searchPlaceholder={effectiveSearchPlaceholder}
searchQuery={searchQuery} searchQuery={searchQuery}
@@ -0,0 +1,76 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@gorhom/portal", () => ({
Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) =>
React.createElement("div", { "data-portal-host": hostName }, children),
PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }),
}));
vi.mock("@gorhom/bottom-sheet", () => ({
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
BottomSheetModal: React.forwardRef(
(
{
children,
stackBehavior,
}: {
children?: React.ReactNode;
stackBehavior?: string;
},
_ref,
) => React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
),
}));
import { IsolatedBottomSheetModal } from ".";
describe("IsolatedBottomSheetModal presentation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => {
act(() => {
root.render(
<>
<IsolatedBottomSheetModal>Settings</IsolatedBottomSheetModal>
<IsolatedBottomSheetModal>Diagnostic</IsolatedBottomSheetModal>
</>,
);
});
expect(
Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
node.getAttribute("data-stack-behavior"),
),
).toEqual(["push", "push"]);
});
it("only replaces when the callsite asks for replacement", () => {
act(() => {
root.render(
<IsolatedBottomSheetModal presentation="replace">Selector</IsolatedBottomSheetModal>,
);
});
expect(
container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"),
).toBe("replace");
});
});
@@ -1,10 +1,8 @@
import { import {
BottomSheetModal as GorhomBottomSheetModal, BottomSheetModal as GorhomBottomSheetModal,
BottomSheetModalProvider,
type BottomSheetModalProps, type BottomSheetModalProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { Portal } from "@gorhom/portal"; import React from "react";
import React, { createContext, useContext } from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ElementRef } from "react"; import type { ElementRef } from "react";
import { import {
@@ -17,50 +15,29 @@ type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
type IsolatedBottomSheetModalProps = Omit< type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps, BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior" "enableDismissOnClose" | "stackBehavior"
>; > & {
presentation?: "push" | "replace";
};
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods; export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
const IsolatedBottomSheetScopeContext = createContext(false);
export const IsolatedBottomSheetModal = forwardRef< export const IsolatedBottomSheetModal = forwardRef<
IsolatedBottomSheetModalRef, IsolatedBottomSheetModalRef,
IsolatedBottomSheetModalProps IsolatedBottomSheetModalProps
>(function IsolatedBottomSheetModal(props, ref) { >(function IsolatedBottomSheetModal(props, ref) {
const isNestedSheet = useContext(IsolatedBottomSheetScopeContext); const { children, presentation = "push", ...bottomSheetProps } = props;
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 = ( const modal = (
<GorhomBottomSheetModal <GorhomBottomSheetModal
{...bottomSheetProps} {...bottomSheetProps}
ref={ref} ref={ref}
enableDismissOnClose enableDismissOnClose
stackBehavior={isNestedSheet ? "push" : "replace"} stackBehavior={presentation}
> >
{scopedChildren} {children}
</GorhomBottomSheetModal> </GorhomBottomSheetModal>
); );
if (isNestedSheet) { return modal;
return modal;
}
return (
<Portal hostName="root">
<BottomSheetModalProvider>{modal}</BottomSheetModalProvider>
</Portal>
);
}); });
export function useIsolatedBottomSheetVisibility({ export function useIsolatedBottomSheetVisibility({
@@ -70,17 +70,17 @@ describe("bottom sheet visibility tracker", () => {
expect(sheet.events).toEqual([]); expect(sheet.events).toEqual([]);
}); });
it("only reports a user close when the sheet was visible", () => { it("does not treat index -1 as a close because stacked sheets can be hidden without dismissing", () => {
const { sheet, tracker, closeCount } = setup(); const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet); tracker.attachController(sheet);
tracker.syncDesired({ visible: true }); tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1); tracker.handleSheetIndexChange(-1);
expect(closeCount()).toBe(1); expect(closeCount()).toBe(0);
tracker.syncDesired({ visible: false }); tracker.syncDesired({ visible: false });
tracker.handleSheetIndexChange(-1); tracker.handleSheetIndexChange(-1);
expect(closeCount()).toBe(1); expect(closeCount()).toBe(0);
}); });
it("reports a dismiss while visible as a close request", () => { it("reports a dismiss while visible as a close request", () => {
@@ -93,7 +93,7 @@ describe("bottom sheet visibility tracker", () => {
expect(closeCount()).toBe(1); expect(closeCount()).toBe(1);
}); });
it("deduplicates close notifications from change and dismiss callbacks", () => { it("reports close once when a hidden sheet is actually dismissed", () => {
const { sheet, tracker, closeCount } = setup(); const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet); tracker.attachController(sheet);
tracker.syncDesired({ visible: true }); tracker.syncDesired({ visible: true });
@@ -110,12 +110,14 @@ describe("bottom sheet visibility tracker", () => {
tracker.syncDesired({ visible: true }); tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1); tracker.handleSheetIndexChange(-1);
tracker.handleSheetDismiss();
expect(closeCount()).toBe(1); expect(closeCount()).toBe(1);
tracker.syncDesired({ visible: false }); tracker.syncDesired({ visible: false });
tracker.syncDesired({ visible: true }); tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1); tracker.handleSheetIndexChange(-1);
tracker.handleSheetDismiss();
expect(closeCount()).toBe(2); expect(closeCount()).toBe(2);
}); });
@@ -128,7 +130,7 @@ describe("bottom sheet visibility tracker", () => {
tracker.attachController(null); tracker.attachController(null);
tracker.attachController(sheet); tracker.attachController(sheet);
expect(closeCount()).toBe(1); expect(closeCount()).toBe(0);
expect(sheet.events).toEqual([{ type: "present" }]); expect(sheet.events).toEqual([{ type: "present" }]);
}); });
@@ -69,7 +69,7 @@ export function createBottomSheetVisibilityTracker(opts: {
}, },
handleSheetIndexChange(index) { handleSheetIndexChange(index) {
if (index !== -1) { if (index !== -1) {
if (phase === "presenting") { if (phase === "presenting" || phase === "dismissing") {
phase = "presented"; phase = "presented";
} }
return; return;
@@ -77,9 +77,6 @@ export function createBottomSheetVisibilityTracker(opts: {
if (phase === "presenting" || phase === "presented") { if (phase === "presenting" || phase === "presented") {
phase = "dismissing"; phase = "dismissing";
} }
if (visible) {
notifyClose();
}
}, },
handleSheetDismiss() { handleSheetDismiss() {
if (visible) { if (visible) {
@@ -8,6 +8,7 @@ interface ProviderSettingsTarget {
interface ProviderSettingsStoreState { interface ProviderSettingsStoreState {
serverId: string | null; serverId: string | null;
provider: string | null; provider: string | null;
visible: boolean;
open: (target: ProviderSettingsTarget) => void; open: (target: ProviderSettingsTarget) => void;
close: () => void; close: () => void;
} }
@@ -15,10 +16,11 @@ interface ProviderSettingsStoreState {
export const useProviderSettingsStore = create<ProviderSettingsStoreState>()((set) => ({ export const useProviderSettingsStore = create<ProviderSettingsStoreState>()((set) => ({
serverId: null, serverId: null,
provider: null, provider: null,
visible: false,
open: ({ serverId, provider }) => { open: ({ serverId, provider }) => {
set({ serverId, provider }); set({ serverId, provider, visible: true });
}, },
close: () => { close: () => {
set({ serverId: null, provider: null }); set({ visible: false });
}, },
})); }));