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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7261-global-modal-outside-dismiss.md
Normal file
7
.changeset/fn-7261-global-modal-outside-dismiss.md
Normal file
@@ -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.
|
||||
@@ -52,6 +52,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
| `shadcnCustomColors` | `Record<string, string>` | `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<string, ModelPricing>` | `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. |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2954,6 +2954,11 @@ export interface GlobalSettings {
|
||||
shadcnCustomColors?: Record<string, string>;
|
||||
/** 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"). */
|
||||
|
||||
@@ -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 (
|
||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
|
||||
<FileBrowserProvider openFile={openFileInBrowser}>
|
||||
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
||||
<ModalDismissPreferenceProvider enabled={dismissModalsOnOutsideClick}>
|
||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
|
||||
<FileBrowserProvider openFile={openFileInBrowser}>
|
||||
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
||||
{isFirstEverBoot ? (
|
||||
<>
|
||||
<DashboardLoader stage={loadingStage} />
|
||||
@@ -1702,9 +1705,10 @@ function AppInner() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</RetryWarningProvider>
|
||||
</FileBrowserProvider>
|
||||
</NavigationHistoryProvider>
|
||||
</RetryWarningProvider>
|
||||
</FileBrowserProvider>
|
||||
</NavigationHistoryProvider>
|
||||
</ModalDismissPreferenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()} role="dialog" aria-modal="true" aria-label={t("agentError.dialogLabel", "Agent error details")}>
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true" aria-label={t("agentError.dialogLabel", "Agent error details")}>
|
||||
<div className="modal agent-error-modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
|
||||
@@ -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(<AgentErrorDetailsModal open={true} onClose={disabledClose} errorText="boom" issueContext={issueContext} />);
|
||||
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(
|
||||
<ModalDismissPreferenceProvider enabled>
|
||||
<AgentErrorDetailsModal open={true} onClose={enabledClose} errorText="boom" issueContext={issueContext} />
|
||||
</ModalDismissPreferenceProvider>,
|
||||
);
|
||||
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(<AgentErrorDetailsModal open={true} onClose={vi.fn()} errorText="report me" issueContext={issueContext} />);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
expect(globalPayload.dismissModalsOnOutsideClick).toBe(true);
|
||||
if (mockUpdateSettings.mock.calls.length > 0) {
|
||||
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(projectPayload.dismissModalsOnOutsideClick).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("saves persistAgentToolOutput only via global settings payload", async () => {
|
||||
renderModal({ initialSection: "global-general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
@@ -71,6 +71,7 @@ const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
"global-general": new Set([
|
||||
"githubTrackingDefaultRepo",
|
||||
"language",
|
||||
"dismissModalsOnOutsideClick",
|
||||
"persistAgentToolOutput",
|
||||
"persistAgentThinkingLogPermanent",
|
||||
"persistAgentThinkingLogEphemeral",
|
||||
|
||||
@@ -21,6 +21,11 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin
|
||||
<small>{t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo.")}</small>
|
||||
</div>
|
||||
<CliBinaryPanel />
|
||||
<div className="form-group">
|
||||
<label htmlFor="dismissModalsOnOutsideClick" className="checkbox-label">
|
||||
<input id="dismissModalsOnOutsideClick" type="checkbox" checked={form.dismissModalsOnOutsideClick === true} onChange={(e) => setForm((f) => ({ ...f, dismissModalsOnOutsideClick: e.target.checked }))}/>{t("settings.globalGeneral.dismissModalsByClickingOutside", " Dismiss modals by clicking outside ")}</label>
|
||||
<small>{t("settings.globalGeneral.dismissModalsByClickingOutsideHint", " When enabled, clicking or tapping a modal backdrop closes the modal. Off by default to prevent accidental dismissal. ")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="persistAgentToolOutput" className="checkbox-label">
|
||||
<input id="persistAgentToolOutput" type="checkbox" checked={form.persistAgentToolOutput === true} onChange={(e) => setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}/>{t("settings.globalGeneral.saveToolOutputInAgentLogs", " Save tool output in agent logs ")}</label>
|
||||
|
||||
@@ -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 (
|
||||
<div data-testid="overlay" {...props}>
|
||||
<div data-testid="modal-content">content</div>
|
||||
@@ -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(<OverlayHarness onClose={onClose} />);
|
||||
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(<OverlayHarness onClose={onClose} enabled />);
|
||||
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(<OverlayHarness onClose={onClose} />);
|
||||
const { getByTestId } = render(<OverlayHarness onClose={onClose} enabled />);
|
||||
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(<OverlayHarness onClose={onClose} />);
|
||||
const { getByTestId } = render(<OverlayHarness onClose={onClose} enabled />);
|
||||
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(
|
||||
<ModalDismissPreferenceProvider enabled>
|
||||
<OverlayHarness onClose={onClose} />
|
||||
</ModalDismissPreferenceProvider>,
|
||||
);
|
||||
const overlay = getByTestId("overlay");
|
||||
|
||||
fireEvent.mouseDown(overlay);
|
||||
fireEvent.mouseUp(overlay);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<QuickChatButtonMode>("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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user