From ebb805d253abbd814f86b2552628dccd3fa17fb7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 01:50:52 -0700 Subject: [PATCH] FN-7261: add global modal dismissal setting Add a global dashboard preference that keeps modal backdrop dismissal off by default while allowing operators to opt in. - Add dismissModalsOnOutsideClick to global settings defaults, schema, docs, and save handling. - Provide the modal dismissal preference through App and gate shared overlay mouse/touch dismissal on it. - Expose the preference in Global General settings and update modal dismissal tests. - Add a changeset for the published CLI package. Files changed: .changeset/fn-7261-global-modal-outside-dismiss.md | 7 ++++ docs/settings-reference.md | 1 + .../core/src/__tests__/settings-defaults.test.ts | 9 +++++ packages/core/src/settings-schema.ts | 5 +++ packages/core/src/types.ts | 5 +++ packages/dashboard/app/App.tsx | 16 +++++---- .../app/components/AgentErrorDetailsModal.tsx | 4 ++- .../__tests__/AgentErrorDetailsModal.test.tsx | 28 +++++++++++++++- .../__tests__/SettingsModal.general.test.tsx | 23 ++++++++++++- .../app/components/settings/save-split.ts | 1 + .../settings/sections/GlobalGeneralSection.tsx | 5 +++ .../app/hooks/__tests__/useOverlayDismiss.test.tsx | 38 ++++++++++++++++++---- packages/dashboard/app/hooks/useAppSettings.ts | 5 +++ packages/dashboard/app/hooks/useOverlayDismiss.ts | 36 +++++++++++++++++--- 14 files changed, 163 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7261 Fusion-Task-Lineage: 4fd5195f-c759-46b4-b567-0d5a9d5b92e3 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7261-global-modal-outside-dismiss.md | 7 ++++ docs/settings-reference.md | 1 + .../src/__tests__/settings-defaults.test.ts | 9 +++++ packages/core/src/settings-schema.ts | 5 +++ packages/core/src/types.ts | 5 +++ packages/dashboard/app/App.tsx | 16 +++++--- .../app/components/AgentErrorDetailsModal.tsx | 4 +- .../__tests__/AgentErrorDetailsModal.test.tsx | 28 +++++++++++++- .../__tests__/SettingsModal.general.test.tsx | 23 ++++++++++- .../app/components/settings/save-split.ts | 1 + .../sections/GlobalGeneralSection.tsx | 5 +++ .../__tests__/useOverlayDismiss.test.tsx | 38 ++++++++++++++++--- .../dashboard/app/hooks/useAppSettings.ts | 5 +++ .../dashboard/app/hooks/useOverlayDismiss.ts | 36 +++++++++++++++--- 14 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 .changeset/fn-7261-global-modal-outside-dismiss.md diff --git a/.changeset/fn-7261-global-modal-outside-dismiss.md b/.changeset/fn-7261-global-modal-outside-dismiss.md new file mode 100644 index 0000000000..e18e3ae6c2 --- /dev/null +++ b/.changeset/fn-7261-global-modal-outside-dismiss.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a global setting to control modal backdrop dismissal. +category: feature +dev: Adds dismissModalsOnOutsideClick as a global-only dashboard preference, defaulting false. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index fb85b27de7..acad382d00 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -52,6 +52,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `shadcnCustomColors` | `Record` | `undefined` | Optional shadcn design-token override map for `"shadcn-custom"` only. Keys are CSS token names such as `--accent`, `--bg`, `--surface`, `--card`, `--border`, `--text`, `--text-muted`, workflow status tokens, and `--color-success`/`--color-warning`/`--color-error`; values must be sanitized `#RGB` or `#RRGGBB` hex colors. Missing or invalid entries fall back to the `shadcn-custom` base defaults and are not applied to other themes. | | `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). | | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | +| `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. | | `defaultProvider` | `string` | `undefined` | Default AI provider. | | `defaultModelId` | `string` | `undefined` | Default AI model ID. | | `modelPricingOverrides` | `Record` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable from Settings → Global Models → View pricing table. | diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 8ece9fa3e6..6f6ac62f3c 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -176,6 +176,15 @@ describe("settings defaults invariants", () => { }); }); + describe("dismissModalsOnOutsideClick default", () => { + it("defaults modal backdrop dismissal off and global-scoped only", () => { + expect(DEFAULT_GLOBAL_SETTINGS.dismissModalsOnOutsideClick).toBe(false); + expect(GLOBAL_SETTINGS_KEYS).toContain("dismissModalsOnOutsideClick"); + expect("dismissModalsOnOutsideClick" in DEFAULT_PROJECT_SETTINGS).toBe(false); + expect(PROJECT_SETTINGS_KEYS).not.toContain("dismissModalsOnOutsideClick"); + }); + }); + describe("mergeIntegrationWorktree default", () => { it("defaults project settings to reuse-task-worktree", () => { expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree"); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 7d24e41f1a..87ef8c55ed 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -71,6 +71,11 @@ export const DEFAULT_GLOBAL_SETTINGS = { colorTheme: "ocean", shadcnCustomColors: undefined, dashboardFontScalePct: 100, + /* + FNXC:ModalDismissal 2026-06-29-00:00: + Fixed dashboard modals must ignore backdrop clicks by default so accidental outside taps do not discard in-progress form state. Operators can globally opt in to the legacy outside-click dismissal behavior. + */ + dismissModalsOnOutsideClick: false, language: undefined, defaultProvider: undefined, defaultModelId: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6ddcd6c0dc..08f17136cc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2954,6 +2954,11 @@ export interface GlobalSettings { shadcnCustomColors?: Record; /** Dashboard font size scale percentage. Bounded to 85-125. Default: 100. */ dashboardFontScalePct?: number; + /** + * FNXC:ModalDismissal 2026-06-29-00:00: + * Modal backdrop dismissal is a global operator preference, not project policy. Default false keeps fixed modal overlays from closing on accidental outside clicks unless the operator opts in. + */ + dismissModalsOnOutsideClick?: boolean; /** Active UI locale (e.g. `"en"`, `"zh-CN"`, `"fr"`). One of `SUPPORTED_LOCALES`. * When unset, each surface resolves the locale at runtime (browser/env * detection) and falls back to `DEFAULT_LOCALE` ("en"). */ diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 6771146c80..c20fdc2025 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -39,6 +39,7 @@ import { ConfirmDialogProvider } from "./hooks/useConfirm"; import { useTheme } from "./hooks/useTheme"; import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./hooks/useModalManager"; import { useAppSettings } from "./hooks/useAppSettings"; +import { ModalDismissPreferenceProvider } from "./hooks/useOverlayDismiss"; import { useDeepLink } from "./hooks/useDeepLink"; import { useFavorites } from "./hooks/useFavorites"; import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; @@ -560,6 +561,7 @@ function AppInner() { openMobileTasksInPopup, quickChatButtonMode, quickChatCloseOnOutsideClick, + dismissModalsOnOutsideClick, maxTotalRetriesBeforeFail, prAuthAvailable, settingsLoaded, @@ -1372,9 +1374,10 @@ function AppInner() { setShowGitHubStarPrompt, }; return ( - - - + + + + {isFirstEverBoot ? ( <> @@ -1702,9 +1705,10 @@ function AppInner() { )} )} - - - + + + + ); } diff --git a/packages/dashboard/app/components/AgentErrorDetailsModal.tsx b/packages/dashboard/app/components/AgentErrorDetailsModal.tsx index 24d7e28091..20a7724316 100644 --- a/packages/dashboard/app/components/AgentErrorDetailsModal.tsx +++ b/packages/dashboard/app/components/AgentErrorDetailsModal.tsx @@ -2,6 +2,7 @@ import "./AgentErrorDetailsModal.css"; import { useMemo, useState } from "react"; import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new"; @@ -53,13 +54,14 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext const [copied, setCopied] = useState(false); const { t } = useTranslation("app"); const issueUrl = useMemo(() => buildAgentErrorIssueUrl(errorText, issueContext), [errorText, issueContext]); + const overlayDismissProps = useOverlayDismiss(onClose); if (!open) { return null; } return ( -
event.target === event.currentTarget && onClose()} role="dialog" aria-modal="true" aria-label={t("agentError.dialogLabel", "Agent error details")}> +

diff --git a/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx b/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx index 2736236367..b9ac0c4e04 100644 --- a/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx @@ -1,8 +1,9 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AgentErrorDetailsModal, AgentErrorIndicator } from "../AgentErrorDetailsModal"; import { loadAllAppCss } from "../../test/cssFixture"; +import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss"; const issueContext = { surface: "AgentsView", @@ -51,6 +52,31 @@ describe("AgentErrorDetailsModal", () => { }); }); + it("gates backdrop dismissal behind the global modal dismiss preference", () => { + const disabledClose = vi.fn(); + const { unmount } = render(); + const disabledOverlay = screen.getByRole("dialog", { name: "Agent error details" }); + + fireEvent.mouseDown(disabledOverlay); + fireEvent.mouseUp(disabledOverlay); + + expect(disabledClose).not.toHaveBeenCalled(); + unmount(); + + const enabledClose = vi.fn(); + render( + + + , + ); + const enabledOverlay = screen.getByRole("dialog", { name: "Agent error details" }); + + fireEvent.mouseDown(enabledOverlay); + fireEvent.mouseUp(enabledOverlay); + + expect(enabledClose).toHaveBeenCalledTimes(1); + }); + it("opens github report link", async () => { const user = userEvent.setup(); render(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 242b21429a..b87436242e 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -513,7 +513,9 @@ describe("SettingsModal", () => { renderModal({ initialSection: "global-general" }); await waitForSettingsModalReady(); - // persistAgentToolOutput defaults to unchecked; Star-on-GitHub control absent. + // Global modal outside-dismiss and persistAgentToolOutput default to unchecked; Star-on-GitHub control absent. + expect(screen.getByRole("checkbox", { name: "Dismiss modals by clicking outside" })).not.toBeChecked(); + expect(screen.getByText(/Off by default to prevent accidental dismissal/i).closest("small")).toBeTruthy(); expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).not.toBeChecked(); expect(screen.queryByRole("checkbox", { name: /Show "Star on GitHub" button in Settings header/i })).toBeNull(); @@ -582,6 +584,25 @@ describe("SettingsModal", () => { expect(screen.getByRole("checkbox", { name: "Save AI thinking for ephemeral / task-worker agents" })).toBeChecked(); }); + it("saves modal outside-dismiss only via global settings payload", async () => { + renderModal({ initialSection: "global-general" }); + await waitForSettingsModalReady(); + + await settingsModalUser.click(screen.getByRole("checkbox", { name: "Dismiss modals by clicking outside" })); + await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateGlobalSettings).toHaveBeenCalled(); + }); + + const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record; + expect(globalPayload.dismissModalsOnOutsideClick).toBe(true); + if (mockUpdateSettings.mock.calls.length > 0) { + const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record; + expect(projectPayload.dismissModalsOnOutsideClick).toBeUndefined(); + } + }); + it("saves persistAgentToolOutput only via global settings payload", async () => { renderModal({ initialSection: "global-general" }); await waitForSettingsModalReady(); diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 36f9e44e51..75200e2b90 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -71,6 +71,7 @@ const GLOBAL_SECTION_KEYS: Record> = { "global-general": new Set([ "githubTrackingDefaultRepo", "language", + "dismissModalsOnOutsideClick", "persistAgentToolOutput", "persistAgentThinkingLogPermanent", "persistAgentThinkingLogEphemeral", diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index ccd9ee6556..83ca337e98 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -21,6 +21,11 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin {t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo.")}

+
+ + {t("settings.globalGeneral.dismissModalsByClickingOutsideHint", " When enabled, clicking or tapping a modal backdrop closes the modal. Off by default to prevent accidental dismissal. ")} +
diff --git a/packages/dashboard/app/hooks/__tests__/useOverlayDismiss.test.tsx b/packages/dashboard/app/hooks/__tests__/useOverlayDismiss.test.tsx index 38e40b90ea..7f8e1abfa4 100644 --- a/packages/dashboard/app/hooks/__tests__/useOverlayDismiss.test.tsx +++ b/packages/dashboard/app/hooks/__tests__/useOverlayDismiss.test.tsx @@ -1,9 +1,9 @@ import { describe, it, expect, vi } from "vitest"; import { render, fireEvent } from "@testing-library/react"; -import { useOverlayDismiss } from "../useOverlayDismiss"; +import { ModalDismissPreferenceProvider, useOverlayDismiss } from "../useOverlayDismiss"; -function OverlayHarness({ onClose }: { onClose: () => void }) { - const props = useOverlayDismiss(onClose); +function OverlayHarness({ onClose, enabled }: { onClose: () => void; enabled?: boolean }) { + const props = useOverlayDismiss(onClose, enabled === undefined ? undefined : { enabled }); return (
content
@@ -12,7 +12,7 @@ function OverlayHarness({ onClose }: { onClose: () => void }) { } describe("useOverlayDismiss", () => { - it("closes on real overlay mouse down/up", () => { + it("does not close on real overlay mouse down/up when the global setting is disabled by default", () => { const onClose = vi.fn(); const { getByTestId } = render(); const overlay = getByTestId("overlay"); @@ -20,12 +20,23 @@ describe("useOverlayDismiss", () => { fireEvent.mouseDown(overlay); fireEvent.mouseUp(overlay); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it("closes on real overlay mouse down/up when enabled", () => { + const onClose = vi.fn(); + const { getByTestId } = render(); + const overlay = getByTestId("overlay"); + + fireEvent.mouseDown(overlay); + fireEvent.mouseUp(overlay); + expect(onClose).toHaveBeenCalledTimes(1); }); it("ignores compatibility mouse sequence immediately after touch", () => { const onClose = vi.fn(); - const { getByTestId } = render(); + const { getByTestId } = render(); const overlay = getByTestId("overlay"); fireEvent.touchStart(overlay); @@ -38,7 +49,7 @@ describe("useOverlayDismiss", () => { it("does not close when mouse starts inside modal and ends on overlay", () => { const onClose = vi.fn(); - const { getByTestId } = render(); + const { getByTestId } = render(); const overlay = getByTestId("overlay"); const modal = getByTestId("modal-content"); @@ -47,4 +58,19 @@ describe("useOverlayDismiss", () => { expect(onClose).toHaveBeenCalledTimes(0); }); + + it("uses the modal dismiss preference provider when no per-call override is supplied", () => { + const onClose = vi.fn(); + const { getByTestId } = render( + + + , + ); + const overlay = getByTestId("overlay"); + + fireEvent.mouseDown(overlay); + fireEvent.mouseUp(overlay); + + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index 2decc0a797..62ab7b400a 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -24,6 +24,7 @@ export interface UseAppSettingsResult { openMobileTasksInPopup: boolean; quickChatButtonMode: QuickChatButtonMode; quickChatCloseOnOutsideClick: boolean; + dismissModalsOnOutsideClick: boolean; showQuickChatFAB: boolean; maxTotalRetriesBeforeFail: number; prAuthAvailable: boolean; @@ -65,6 +66,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { const [openMobileTasksInPopup, setOpenMobileTasksInPopup] = useState(false); const [quickChatButtonMode, setQuickChatButtonMode] = useState("off"); const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true); + const [dismissModalsOnOutsideClick, setDismissModalsOnOutsideClick] = useState(false); const [showQuickChatFAB, setShowQuickChatFAB] = useState(false); const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25); const [prAuthAvailable, setPrAuthAvailable] = useState(false); @@ -116,6 +118,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { : "off"; setQuickChatButtonMode(nextQuickChatButtonMode); setQuickChatCloseOnOutsideClick(settings.quickChatCloseOnOutsideClick !== false); + setDismissModalsOnOutsideClick(settings.dismissModalsOnOutsideClick === true); setShowQuickChatFAB(nextQuickChatButtonMode === "floating"); setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25); setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true); @@ -151,6 +154,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { setOpenTasksInRightSidebar(false); setOpenMobileTasksInPopup(false); setQuickChatCloseOnOutsideClick(true); + setDismissModalsOnOutsideClick(false); setTodosEnabled(true); setGoalsEnabled(true); void refresh(); @@ -254,6 +258,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { openMobileTasksInPopup, quickChatButtonMode, quickChatCloseOnOutsideClick, + dismissModalsOnOutsideClick, showQuickChatFAB, maxTotalRetriesBeforeFail, prAuthAvailable, diff --git a/packages/dashboard/app/hooks/useOverlayDismiss.ts b/packages/dashboard/app/hooks/useOverlayDismiss.ts index 2846dd5607..3982df88e9 100644 --- a/packages/dashboard/app/hooks/useOverlayDismiss.ts +++ b/packages/dashboard/app/hooks/useOverlayDismiss.ts @@ -1,4 +1,20 @@ -import { useCallback, useEffect, useRef } from "react"; +import { createContext, createElement, useCallback, useContext, useEffect, useRef, type ReactNode } from "react"; + +const ModalDismissPreferenceContext = createContext(false); + +export function ModalDismissPreferenceProvider({ + enabled, + children, +}: { + enabled: boolean; + children: ReactNode; +}) { + return createElement(ModalDismissPreferenceContext.Provider, { value: enabled }, children); +} + +export function useModalDismissPreference(): boolean { + return useContext(ModalDismissPreferenceContext); +} /** * Returns props for a modal-overlay element that dismisses only when a real @@ -15,12 +31,18 @@ import { useCallback, useEffect, useRef } from "react"; * does NOT need to stopPropagation — mousedown on the modal sets the ref to * `false`, so the overlay's mouseup handler bails. */ -export function useOverlayDismiss(onClose: () => void): { +export function useOverlayDismiss(onClose: () => void, options?: { enabled?: boolean }): { onMouseDown: (e: React.MouseEvent) => void; onMouseUp: (e: React.MouseEvent) => void; onTouchStart: () => void; onTouchEnd: () => void; } { + const contextEnabled = useModalDismissPreference(); + /* + FNXC:ModalDismissal 2026-06-29-00:00: + Modal backdrop dismissal is globally default-off to prevent accidental data loss. Components using this helper keep explicit close, cancel, and Escape paths, while backdrop mouse/touch dismissal only runs when the global preference is enabled. + */ + const dismissEnabled = options?.enabled ?? contextEnabled; const startedOnOverlayRef = useRef(false); const lastTouchAtRef = useRef(0); @@ -29,6 +51,10 @@ export function useOverlayDismiss(onClose: () => void): { }, []); const onMouseDown = useCallback((e: React.MouseEvent) => { + if (!dismissEnabled) { + startedOnOverlayRef.current = false; + return; + } // Android/webview may emit compatibility mouse events right after touchend. // Ignore those so a newly-mounted overlay is not dismissed immediately. if (Date.now() - lastTouchAtRef.current < 500) { @@ -36,7 +62,7 @@ export function useOverlayDismiss(onClose: () => void): { return; } startedOnOverlayRef.current = e.target === e.currentTarget; - }, []); + }, [dismissEnabled]); useEffect(() => { if (typeof document === "undefined") return; @@ -56,11 +82,11 @@ export function useOverlayDismiss(onClose: () => void): { const onMouseUp = useCallback( (e: React.MouseEvent) => { - const shouldClose = startedOnOverlayRef.current && e.target === e.currentTarget; + const shouldClose = dismissEnabled && startedOnOverlayRef.current && e.target === e.currentTarget; startedOnOverlayRef.current = false; if (shouldClose) onClose(); }, - [onClose], + [dismissEnabled, onClose], ); return { onMouseDown, onMouseUp, onTouchStart: markTouch, onTouchEnd: markTouch };