Skip to content

Commit 93fd219

Browse files
refactor(frontend): improve dialog component and settings navigation
1 parent efbe3ca commit 93fd219

3 files changed

Lines changed: 268 additions & 87 deletions

File tree

frontend/src/components/settings/SettingsDialog.tsx

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useRef, useEffect, useCallback } from 'react'
1+
import { useState, useEffect, useCallback } from 'react'
22
import { GeneralSettings } from '@/components/settings/GeneralSettings'
33
import { GitSettings } from '@/components/settings/GitSettings'
44
import { KeyboardShortcuts } from '@/components/settings/KeyboardShortcuts'
@@ -12,7 +12,6 @@ import { Dialog, DialogContent } from '@/components/ui/dialog'
1212
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
1313
import { Settings2, Keyboard, Code, ChevronLeft, Key, GitBranch, User, Volume2, Bell, X } from 'lucide-react'
1414
import { Button } from '@/components/ui/button'
15-
import { useSwipeBack } from '@/hooks/useMobile'
1615
import { useSettingsDialog } from '@/hooks/useSettingsDialog'
1716

1817
type SettingsView = 'menu' | 'general' | 'git' | 'shortcuts' | 'opencode' | 'providers' | 'account' | 'voice' | 'notifications'
@@ -21,7 +20,6 @@ export function SettingsDialog() {
2120
const { isOpen, close, activeTab, setActiveTab } = useSettingsDialog()
2221
const [mobileView, setMobileView] = useState<SettingsView>('menu')
2322
const [sectionHistory, setSectionHistory] = useState<SettingsView[]>([])
24-
const contentRef = useRef<HTMLDivElement>(null)
2523

2624
const pushSectionHistory = useCallback((view: SettingsView) => {
2725
if (view === 'menu') return
@@ -54,16 +52,6 @@ export function SettingsDialog() {
5452
setMobileView('menu')
5553
}, [mobileView, sectionHistory, close, setActiveTab])
5654

57-
const { bind: bindSwipe, swipeStyles } = useSwipeBack(close, {
58-
enabled: isOpen,
59-
canBack: () => mobileView !== 'menu',
60-
onBack: handleSettingsBack,
61-
})
62-
63-
useEffect(() => {
64-
return bindSwipe(contentRef.current)
65-
}, [bindSwipe])
66-
6755
useEffect(() => {
6856
if (!isOpen) {
6957
setMobileView('menu')
@@ -105,13 +93,13 @@ export function SettingsDialog() {
10593
}
10694

10795
return (
108-
<Dialog open={isOpen} modal={false}>
109-
<DialogContent
110-
ref={contentRef}
111-
className="inset-0 w-full h-full max-w-none max-h-none p-0 rounded-none bg-gradient-to-br from-background via-background to-background border-border overflow-hidden !flex !flex-col"
112-
style={swipeStyles}
113-
fullscreen
114-
>
96+
<Dialog open={isOpen} modal={false} onOpenChange={(open) => !open && close()}>
97+
<DialogContent
98+
className="inset-0 w-full h-full max-w-none max-h-none p-0 rounded-none bg-gradient-to-br from-background via-background to-background border-border overflow-hidden !flex !flex-col"
99+
fullscreen
100+
canSwipeBack={() => mobileView !== 'menu'}
101+
onSwipeBack={handleSettingsBack}
102+
>
115103
<div className="hidden sm:flex sm:flex-col sm:h-full">
116104
<div className="sticky top-0 z-10 bg-gradient-to-b from-background via-background to-transparent border-b border-border backdrop-blur-sm px-6 py-4 flex-shrink-0 flex items-center justify-between">
117105
<h2 className="text-2xl font-semibold bg-gradient-to-r from-foreground to-muted-foreground bg-clip-text text-transparent">

frontend/src/components/ui/dialog.test.tsx

Lines changed: 237 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi } from "vitest";
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { render, screen } from "@testing-library/react";
33
import {
44
Dialog,
@@ -7,7 +7,30 @@ import {
77
DialogTitle,
88
} from "./dialog";
99

10+
function withMobileViewport(fn: () => void) {
11+
const originalWidth = window.innerWidth
12+
Object.defineProperty(window, 'innerWidth', {
13+
writable: true,
14+
configurable: true,
15+
value: 375,
16+
})
17+
fn()
18+
Object.defineProperty(window, 'innerWidth', {
19+
writable: true,
20+
configurable: true,
21+
value: originalWidth,
22+
})
23+
}
24+
1025
describe("DialogContent", () => {
26+
beforeEach(() => {
27+
Object.defineProperty(window, 'innerWidth', {
28+
writable: true,
29+
configurable: true,
30+
value: 375,
31+
})
32+
})
33+
1134
it("applies safe-area padding when fullscreen prop is true", () => {
1235
render(
1336
<Dialog open>
@@ -162,64 +185,225 @@ describe("DialogContent", () => {
162185
expect(content).toHaveStyle({ paddingTop: "env(safe-area-inset-top, 0px)" });
163186
});
164187

165-
it('renders hidden close trigger when mobileSwipeToClose is enabled', () => {
166-
const onOpenChange = vi.fn();
167-
render(
168-
<Dialog open onOpenChange={onOpenChange}>
169-
<DialogContent mobileFullscreen mobileSwipeToClose>
170-
<DialogHeader>
171-
<DialogTitle>Swipe Dialog</DialogTitle>
172-
</DialogHeader>
173-
</DialogContent>
174-
</Dialog>
175-
);
176-
const closeTrigger = document.querySelector('[data-swipe-close-trigger]');
177-
expect(closeTrigger).toBeInTheDocument();
188+
it('renders hidden close trigger by default on mobile', () => {
189+
withMobileViewport(() => {
190+
const onOpenChange = vi.fn();
191+
render(
192+
<Dialog open onOpenChange={onOpenChange}>
193+
<DialogContent>
194+
<DialogHeader>
195+
<DialogTitle>Swipe Dialog</DialogTitle>
196+
</DialogHeader>
197+
</DialogContent>
198+
</Dialog>
199+
);
200+
const closeTrigger = document.querySelector('[data-swipe-close-trigger]');
201+
expect(closeTrigger).toBeInTheDocument();
202+
});
203+
});
204+
205+
it('does not render hidden close trigger when mobileSwipeToClose is false', () => {
206+
withMobileViewport(() => {
207+
render(
208+
<Dialog open>
209+
<DialogContent mobileSwipeToClose={false}>
210+
<DialogHeader>
211+
<DialogTitle>Swipe Dialog</DialogTitle>
212+
</DialogHeader>
213+
</DialogContent>
214+
</Dialog>
215+
);
216+
const closeTrigger = document.querySelector('[data-swipe-close-trigger]');
217+
expect(closeTrigger).not.toBeInTheDocument();
218+
});
178219
});
179220

180221
it('closes dialog when hidden close trigger is activated', () => {
181-
const onOpenChange = vi.fn();
182-
render(
183-
<Dialog open onOpenChange={onOpenChange}>
184-
<DialogContent mobileFullscreen mobileSwipeToClose>
185-
<DialogHeader>
186-
<DialogTitle>Swipe Dialog</DialogTitle>
187-
</DialogHeader>
188-
</DialogContent>
189-
</Dialog>
190-
);
191-
192-
const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null;
193-
expect(closeTrigger).toBeInTheDocument();
194-
195-
if (closeTrigger) {
196-
closeTrigger.click();
222+
withMobileViewport(() => {
223+
const onOpenChange = vi.fn();
224+
render(
225+
<Dialog open onOpenChange={onOpenChange}>
226+
<DialogContent>
227+
<DialogHeader>
228+
<DialogTitle>Swipe Dialog</DialogTitle>
229+
</DialogHeader>
230+
</DialogContent>
231+
</Dialog>
232+
);
233+
234+
const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null;
235+
expect(closeTrigger).toBeInTheDocument();
236+
237+
if (closeTrigger) {
238+
closeTrigger.click();
239+
expect(onOpenChange).toHaveBeenCalledWith(false);
240+
}
241+
});
242+
});
243+
244+
it('calls onSwipeBack when canSwipeBack is true and swipe completes', () => {
245+
withMobileViewport(() => {
246+
const mockOnSwipeBack = vi.fn();
247+
const onOpenChange = vi.fn();
248+
render(
249+
<Dialog open onOpenChange={onOpenChange}>
250+
<DialogContent
251+
canSwipeBack={() => true}
252+
onSwipeBack={mockOnSwipeBack}
253+
data-testid="swipe-dialog"
254+
>
255+
Content
256+
</DialogContent>
257+
</Dialog>
258+
);
259+
260+
const content = screen.getByTestId('swipe-dialog');
261+
262+
content.dispatchEvent(new TouchEvent('touchstart', {
263+
touches: [{ clientX: 10, clientY: 100 }] as any,
264+
}));
265+
content.dispatchEvent(new TouchEvent('touchmove', {
266+
touches: [{ clientX: 100, clientY: 100 }] as any,
267+
}));
268+
content.dispatchEvent(new TouchEvent('touchend', {
269+
changedTouches: [{ clientX: 100, clientY: 100 }] as any,
270+
}));
271+
272+
expect(mockOnSwipeBack).toHaveBeenCalled();
273+
expect(onOpenChange).not.toHaveBeenCalled();
274+
});
275+
});
276+
277+
it('attempts close when canSwipeBack is false and swipe completes', () => {
278+
withMobileViewport(() => {
279+
const mockOnSwipeBack = vi.fn();
280+
const onOpenChange = vi.fn();
281+
render(
282+
<Dialog open onOpenChange={onOpenChange}>
283+
<DialogContent
284+
canSwipeBack={() => false}
285+
onSwipeBack={mockOnSwipeBack}
286+
data-testid="swipe-dialog"
287+
>
288+
Content
289+
</DialogContent>
290+
</Dialog>
291+
);
292+
293+
const content = screen.getByTestId('swipe-dialog');
294+
content.dispatchEvent(new TouchEvent('touchstart', {
295+
touches: [{ clientX: 10, clientY: 100 }] as any,
296+
}));
297+
content.dispatchEvent(new TouchEvent('touchmove', {
298+
touches: [{ clientX: 100, clientY: 100 }] as any,
299+
}));
300+
content.dispatchEvent(new TouchEvent('touchend', {
301+
changedTouches: [{ clientX: 100, clientY: 100 }] as any,
302+
}));
303+
304+
expect(mockOnSwipeBack).not.toHaveBeenCalled();
197305
expect(onOpenChange).toHaveBeenCalledWith(false);
198-
}
306+
});
307+
});
308+
309+
it('applies safe-area style and hidden trigger for mobileFullscreen', () => {
310+
withMobileViewport(() => {
311+
render(
312+
<Dialog open>
313+
<DialogContent mobileFullscreen data-testid="dialog-content">
314+
Content
315+
</DialogContent>
316+
</Dialog>
317+
);
318+
const content = screen.getByTestId("dialog-content");
319+
expect(content).toHaveStyle({ paddingTop: "env(safe-area-inset-top, 0px)" });
320+
expect(document.querySelector('[data-swipe-close-trigger]')).toBeInTheDocument();
321+
});
322+
});
323+
324+
it('does not apply transform styles to non-fullscreen dialogs', () => {
325+
withMobileViewport(() => {
326+
render(
327+
<Dialog open>
328+
<DialogContent data-testid="dialog-content">
329+
Content
330+
</DialogContent>
331+
</Dialog>
332+
);
333+
const content = screen.getByTestId("dialog-content");
334+
const style = content.getAttribute("style") || "";
335+
expect(style).not.toMatch(/transform/);
336+
});
337+
});
338+
339+
it('applies swipe transform to fullscreen dialogs during touchmove', () => {
340+
withMobileViewport(() => {
341+
render(
342+
<Dialog open>
343+
<DialogContent fullscreen data-testid="dialog-content">
344+
Content
345+
</DialogContent>
346+
</Dialog>
347+
);
348+
const content = screen.getByTestId("dialog-content");
349+
content.dispatchEvent(new TouchEvent('touchstart', {
350+
touches: [{ clientX: 10, clientY: 100 }] as any,
351+
}));
352+
content.dispatchEvent(new TouchEvent('touchmove', {
353+
touches: [{ clientX: 50, clientY: 100 }] as any,
354+
}));
355+
356+
const style = content.getAttribute("style") || "";
357+
expect(style).toMatch(/transform/);
358+
});
359+
});
360+
361+
it('does not apply swipe transform to non-fullscreen dialogs during touchmove', () => {
362+
withMobileViewport(() => {
363+
render(
364+
<Dialog open>
365+
<DialogContent data-testid="dialog-content">
366+
Content
367+
</DialogContent>
368+
</Dialog>
369+
);
370+
const content = screen.getByTestId("dialog-content");
371+
content.dispatchEvent(new TouchEvent('touchstart', {
372+
touches: [{ clientX: 10, clientY: 100 }] as any,
373+
}));
374+
content.dispatchEvent(new TouchEvent('touchmove', {
375+
touches: [{ clientX: 50, clientY: 100 }] as any,
376+
}));
377+
378+
const style = content.getAttribute("style") || "";
379+
expect(style).not.toMatch(/transform/);
380+
});
199381
});
200382

201383
it('binds swipe handler when mobileSwipeToClose and mobileFullscreen are enabled', () => {
202-
const onOpenChange = vi.fn();
203-
render(
204-
<Dialog open onOpenChange={onOpenChange}>
205-
<DialogContent mobileFullscreen mobileSwipeToClose data-testid="swipe-dialog">
206-
<DialogHeader>
207-
<DialogTitle>Swipe Dialog</DialogTitle>
208-
</DialogHeader>
209-
</DialogContent>
210-
</Dialog>
211-
);
212-
213-
const content = screen.getByTestId('swipe-dialog');
214-
const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null;
215-
216-
expect(content).toBeInTheDocument();
217-
expect(closeTrigger).toBeInTheDocument();
218-
219-
const clickSpy = vi.spyOn(closeTrigger as HTMLButtonElement, 'click');
220-
closeTrigger?.click();
221-
222-
expect(clickSpy).toHaveBeenCalled();
223-
expect(onOpenChange).toHaveBeenCalledWith(false);
384+
withMobileViewport(() => {
385+
const onOpenChange = vi.fn();
386+
render(
387+
<Dialog open onOpenChange={onOpenChange}>
388+
<DialogContent mobileFullscreen mobileSwipeToClose data-testid="swipe-dialog">
389+
<DialogHeader>
390+
<DialogTitle>Swipe Dialog</DialogTitle>
391+
</DialogHeader>
392+
</DialogContent>
393+
</Dialog>
394+
);
395+
396+
const content = screen.getByTestId('swipe-dialog');
397+
const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null;
398+
399+
expect(content).toBeInTheDocument();
400+
expect(closeTrigger).toBeInTheDocument();
401+
402+
const clickSpy = vi.spyOn(closeTrigger as HTMLButtonElement, 'click');
403+
closeTrigger?.click();
404+
405+
expect(clickSpy).toHaveBeenCalled();
406+
expect(onOpenChange).toHaveBeenCalledWith(false);
407+
});
224408
});
225409
});

0 commit comments

Comments
 (0)