Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 51 additions & 8 deletions ui/goose2/src/features/settings/ui/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -51,6 +59,7 @@ export function SettingsModal({
const [activeSection, setActiveSection] = useState<SectionId>(initialSection);
const [isLoaded, setIsLoaded] = useState(false);
const modalRootRef = useRef<HTMLDivElement>(null);
const backdropPointerDownRef = useRef<{ x: number; y: number } | null>(null);

// Trigger entrance animations after mount
useEffect(() => {
Expand Down Expand Up @@ -85,27 +94,61 @@ export function SettingsModal({
const activeSectionLabel =
navItems.find((item) => item.id === activeSection)?.label ?? t("title");

const handleBackdropPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) {
backdropPointerDownRef.current = null;
return;
}
backdropPointerDownRef.current = {
x: event.clientX,
y: event.clientY,
};
};

const handleBackdropClick = (event: MouseEvent<HTMLDivElement>) => {
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.
<div
ref={modalRootRef}
role="dialog"
aria-modal="true"
aria-label={activeSectionLabel}
className={cn(
"fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm transition-opacity duration-300",
"fixed inset-0 z-50 flex items-center justify-center transition-opacity duration-300",
isLoaded ? "opacity-100" : "opacity-0",
)}
onClick={onClose}
>
{/* 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. */}
<div
data-testid="settings-backdrop"
data-tauri-drag-region
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
onPointerDown={handleBackdropPointerDown}
onClick={handleBackdropClick}
/>
<div
className={cn(
"flex h-[min(600px,calc(100vh-4rem))] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden rounded-xl border bg-background shadow-modal transition-opacity duration-300 ease-out",
"relative z-10 flex h-[min(600px,calc(100vh-4rem))] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden rounded-xl border bg-background shadow-modal transition-opacity duration-300 ease-out",
isLoaded ? "opacity-100" : "opacity-0",
)}
onClick={(e) => e.stopPropagation()}
>
{/* Sidebar */}
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SettingsModal } from "../SettingsModal";

vi.mock("../AppearanceSettings", () => ({
AppearanceSettings: () => <div>Appearance settings</div>,
}));

vi.mock("../ProvidersSettings", () => ({
ProvidersSettings: () => <div>Provider settings</div>,
}));

vi.mock("../VoiceInputSettings", () => ({
VoiceInputSettings: () => <div>Voice settings</div>,
}));

vi.mock("../GeneralSettings", () => ({
GeneralSettings: () => <div>General settings</div>,
}));

vi.mock("../CompactionSettings", () => ({
CompactionSettings: () => <div>Compaction settings</div>,
}));

vi.mock("../ProjectsSettings", () => ({
ProjectsSettings: () => <div>Projects settings</div>,
}));

vi.mock("../ChatsSettings", () => ({
ChatsSettings: () => <div>Chats settings</div>,
}));

vi.mock("../DoctorSettings", () => ({
DoctorSettings: () => <div>Doctor settings</div>,
}));

vi.mock("../AboutSettings", () => ({
AboutSettings: () => <div>About settings</div>,
}));

describe("SettingsModal", () => {
it("closes on a backdrop click", () => {
const onClose = vi.fn();

render(<SettingsModal onClose={onClose} />);

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(<SettingsModal onClose={onClose} />);

const backdrop = screen.getByTestId("settings-backdrop");
fireEvent.pointerDown(backdrop, { clientX: 20, clientY: 20 });
fireEvent.click(backdrop, { clientX: 44, clientY: 20 });

expect(onClose).not.toHaveBeenCalled();
});
});
Loading