Keep settings open during window drag

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-05-04 22:15:19 -07:00
parent de471bc2b2
commit fb429659db
2 changed files with 116 additions and 8 deletions
@@ -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<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(() => {
@@ -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
@@ -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();
});
});