Fix stacked provider settings sheets
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
|||||||
+7
-5
@@ -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 });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user