diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index c2f57be606..5440833d9e 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -1,4 +1,10 @@ -import { useState, useEffect, useRef } from "react"; +import { + useState, + useEffect, + useRef, + type MouseEvent, + type PointerEvent, +} from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -36,6 +42,8 @@ const NAV_ITEMS = [ { id: "about", labelKey: "nav.about", icon: Info }, ] as const; +const BACKDROP_CLOSE_DRAG_THRESHOLD = 4; + export type SectionId = (typeof NAV_ITEMS)[number]["id"]; interface SettingsModalProps { @@ -51,6 +59,7 @@ export function SettingsModal({ const [activeSection, setActiveSection] = useState(initialSection); const [isLoaded, setIsLoaded] = useState(false); const modalRootRef = useRef(null); + const backdropPointerDownRef = useRef<{ x: number; y: number } | null>(null); // Trigger entrance animations after mount useEffect(() => { @@ -85,27 +94,61 @@ export function SettingsModal({ const activeSectionLabel = navItems.find((item) => item.id === activeSection)?.label ?? t("title"); + const handleBackdropPointerDown = (event: PointerEvent) => { + if (event.target !== event.currentTarget) { + backdropPointerDownRef.current = null; + return; + } + backdropPointerDownRef.current = { + x: event.clientX, + y: event.clientY, + }; + }; + + const handleBackdropClick = (event: MouseEvent) => { + if (event.target !== event.currentTarget) return; + + const pointerDown = backdropPointerDownRef.current; + backdropPointerDownRef.current = null; + + if (!pointerDown) { + onClose(); + return; + } + + const deltaX = event.clientX - pointerDown.x; + const deltaY = event.clientY - pointerDown.y; + const moved = Math.hypot(deltaX, deltaY); + if (moved <= BACKDROP_CLOSE_DRAG_THRESHOLD) { + onClose(); + } + }; + return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: Escape is handled by the document listener while the backdrop only handles pointer dismissal.
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation on inner container is not a meaningful interaction */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: click handler only prevents backdrop dismiss propagation */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: Escape is handled by the document listener while the backdrop only handles pointer dismissal. */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop distinguishes click dismissal from window dragging. */} +
e.stopPropagation()} > {/* Sidebar */}
({ + AppearanceSettings: () =>
Appearance settings
, +})); + +vi.mock("../ProvidersSettings", () => ({ + ProvidersSettings: () =>
Provider settings
, +})); + +vi.mock("../VoiceInputSettings", () => ({ + VoiceInputSettings: () =>
Voice settings
, +})); + +vi.mock("../GeneralSettings", () => ({ + GeneralSettings: () =>
General settings
, +})); + +vi.mock("../CompactionSettings", () => ({ + CompactionSettings: () =>
Compaction settings
, +})); + +vi.mock("../ProjectsSettings", () => ({ + ProjectsSettings: () =>
Projects settings
, +})); + +vi.mock("../ChatsSettings", () => ({ + ChatsSettings: () =>
Chats settings
, +})); + +vi.mock("../DoctorSettings", () => ({ + DoctorSettings: () =>
Doctor settings
, +})); + +vi.mock("../AboutSettings", () => ({ + AboutSettings: () =>
About settings
, +})); + +describe("SettingsModal", () => { + it("closes on a backdrop click", () => { + const onClose = vi.fn(); + + render(); + + const backdrop = screen.getByTestId("settings-backdrop"); + fireEvent.pointerDown(backdrop, { clientX: 20, clientY: 20 }); + fireEvent.click(backdrop, { clientX: 20, clientY: 20 }); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("keeps settings open when the backdrop pointer moves like a window drag", () => { + const onClose = vi.fn(); + + render(); + + const backdrop = screen.getByTestId("settings-backdrop"); + fireEvent.pointerDown(backdrop, { clientX: 20, clientY: 20 }); + fireEvent.click(backdrop, { clientX: 44, clientY: 20 }); + + expect(onClose).not.toHaveBeenCalled(); + }); +});