Skip to content

Commit 74d23c4

Browse files
authored
Merge pull request #326 from frostaura/copilot/fix-health-longevity-modals
Unify Health & Longevity edit dialogs on the shared frosted modal shell
2 parents de9d44d + 30073a0 commit 74d23c4

3 files changed

Lines changed: 293 additions & 114 deletions

File tree

src/frontend/src/components/organisms/FrostedModalShell.tsx

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CSSProperties, MouseEventHandler, ReactNode, RefObject } from 'react';
1+
import { useEffect, useRef, type CSSProperties, type MouseEventHandler, type ReactNode, type RefObject } from 'react';
22
import { X } from 'lucide-react';
33

44
import { AppModalPortal } from '@components/atoms/AppModalPortal';
@@ -12,6 +12,8 @@ interface FrostedModalShellProps {
1212
children: ReactNode;
1313
closeButtonLabel?: string;
1414
contentClassName?: string;
15+
dismissOnBackdrop?: boolean;
16+
dismissOnEscape?: boolean;
1517
headerClassName?: string;
1618
maxWidthClassName?: string;
1719
modalRef?: RefObject<HTMLDivElement | null>;
@@ -34,6 +36,8 @@ export function FrostedModalShell({
3436
children,
3537
closeButtonLabel = 'Close modal',
3638
contentClassName,
39+
dismissOnBackdrop = false,
40+
dismissOnEscape = false,
3741
headerClassName,
3842
maxWidthClassName = 'max-w-[var(--layout-auth-modal-max-width)]',
3943
modalRef,
@@ -48,6 +52,48 @@ export function FrostedModalShell({
4852
titleIcon,
4953
titleIconClassName = 'auth-accent-icon-tone',
5054
}: FrostedModalShellProps) {
55+
const fallbackModalRef = useRef<HTMLDivElement>(null);
56+
const mouseDownTargetRef = useRef<EventTarget | null>(null);
57+
const resolvedModalRef = modalRef ?? fallbackModalRef;
58+
59+
useEffect(() => {
60+
if (!dismissOnEscape || !onClose) {
61+
return;
62+
}
63+
64+
const handleEscape = (event: KeyboardEvent) => {
65+
if (event.key === 'Escape') {
66+
onClose();
67+
}
68+
};
69+
70+
window.addEventListener('keydown', handleEscape);
71+
return () => window.removeEventListener('keydown', handleEscape);
72+
}, [dismissOnEscape, onClose]);
73+
74+
const handleBackdropMouseDown: MouseEventHandler<HTMLDivElement> = (event) => {
75+
if (dismissOnBackdrop && onClose) {
76+
mouseDownTargetRef.current = event.target;
77+
}
78+
79+
onMouseDown?.(event);
80+
};
81+
82+
const handleBackdropMouseUp: MouseEventHandler<HTMLDivElement> = (event) => {
83+
if (
84+
dismissOnBackdrop &&
85+
onClose &&
86+
resolvedModalRef.current &&
87+
!resolvedModalRef.current.contains(event.target as Node) &&
88+
mouseDownTargetRef.current === event.target
89+
) {
90+
onClose();
91+
}
92+
93+
mouseDownTargetRef.current = null;
94+
onMouseUp?.(event);
95+
};
96+
5197
return (
5298
<AppModalPortal>
5399
<div
@@ -61,11 +107,11 @@ export function FrostedModalShell({
61107
aria-modal="true"
62108
aria-label={ariaLabel ?? title}
63109
onClick={onBackdropClick}
64-
onMouseDown={onMouseDown}
65-
onMouseUp={onMouseUp}
110+
onMouseDown={handleBackdropMouseDown}
111+
onMouseUp={handleBackdropMouseUp}
66112
>
67113
<div
68-
ref={modalRef}
114+
ref={resolvedModalRef}
69115
className={cn(
70116
'relative w-full max-h-[calc(100dvh-var(--space-sm)-env(safe-area-inset-bottom))] overflow-y-auto scroll-pb-[calc(var(--space-lg)+env(safe-area-inset-bottom))] rounded-[var(--radius-auth-shell)] sm:mx-4 sm:max-h-[calc(100vh-2rem)] sm:scroll-pb-space-lg',
71117
maxWidthClassName,
@@ -90,13 +136,13 @@ export function FrostedModalShell({
90136
<div className="flex items-center justify-between gap-space-md">
91137
<div className="flex min-w-0 items-center gap-space-md">
92138
{titleIcon ? (
93-
<div
94-
className={cn(
139+
<div
140+
className={cn(
95141
'flex h-[var(--layout-frosted-modal-title-icon-size)] w-[var(--layout-frosted-modal-title-icon-size)] shrink-0 items-center justify-center rounded-full',
96-
titleIconClassName,
97-
)}
98-
data-testid="frosted-modal-title-icon"
99-
>
142+
titleIconClassName,
143+
)}
144+
data-testid="frosted-modal-title-icon"
145+
>
100146
{titleIcon}
101147
</div>
102148
) : null}

src/frontend/src/pages/__tests__/HealthDetailSections.test.tsx

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
22
import { Activity } from 'lucide-react';
3-
import { describe, expect, it, vi } from 'vitest';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
44

5-
import { HealthDetailSections } from '../healthDetailSections';
5+
import { HealthDetailSections, HealthEditModals } from '../healthDetailSections';
66

77
vi.mock('react-hot-toast', () => ({
88
default: {
@@ -30,6 +30,10 @@ vi.mock('@/services/endpoints/metrics', () => ({
3030
}));
3131

3232
describe('HealthDetailSections', () => {
33+
beforeEach(() => {
34+
document.body.style.overflow = '';
35+
});
36+
3337
it('opens the shared quick log modal with the clicked pending metric preselected', async () => {
3438
render(
3539
<HealthDetailSections
@@ -65,4 +69,102 @@ describe('HealthDetailSections', () => {
6569
expect(screen.getByLabelText(/Value \*/i)).toHaveFocus();
6670
});
6771
});
72+
73+
it('renders the shared frosted shell for the edit target modal', () => {
74+
render(
75+
<HealthEditModals
76+
editingMetric={{
77+
id: 'metric-sleep',
78+
code: 'sleep_hours',
79+
name: 'Sleep Hours',
80+
unit: 'hours',
81+
targetDirection: 'AtOrAbove',
82+
} as never}
83+
setEditingMetric={vi.fn()}
84+
editTargetValue="8"
85+
setEditTargetValue={vi.fn()}
86+
editTargetDirection="AtOrAbove"
87+
setEditTargetDirection={vi.fn()}
88+
handleSaveTarget={vi.fn().mockResolvedValue(undefined)}
89+
editingModel={null}
90+
setEditingModel={vi.fn()}
91+
editModelParams={{}}
92+
setEditModelParams={vi.fn()}
93+
handleSaveModel={vi.fn().mockResolvedValue(undefined)}
94+
/>,
95+
);
96+
97+
const dialog = screen.getByRole('dialog', { name: /Edit target for Sleep Hours/i });
98+
99+
expect(screen.getByRole('heading', { name: /Edit Target: Sleep Hours/i })).toBeInTheDocument();
100+
expect(dialog).toHaveAttribute('data-app-modal', 'true');
101+
expect(dialog).toHaveClass('auth-shell-backdrop');
102+
expect(screen.getByText(/Update the target shown across health tracking and longevity guidance\./i)).toBeInTheDocument();
103+
expect(screen.getByRole('button', { name: /Close edit target modal for Sleep Hours/i })).toBeInTheDocument();
104+
});
105+
106+
it('closes the edit target modal on backdrop interaction', () => {
107+
const setEditingMetric = vi.fn();
108+
109+
render(
110+
<HealthEditModals
111+
editingMetric={{
112+
id: 'metric-sleep',
113+
code: 'sleep_hours',
114+
name: 'Sleep Hours',
115+
unit: 'hours',
116+
targetDirection: 'AtOrAbove',
117+
} as never}
118+
setEditingMetric={setEditingMetric}
119+
editTargetValue="8"
120+
setEditTargetValue={vi.fn()}
121+
editTargetDirection="AtOrAbove"
122+
setEditTargetDirection={vi.fn()}
123+
handleSaveTarget={vi.fn().mockResolvedValue(undefined)}
124+
editingModel={null}
125+
setEditingModel={vi.fn()}
126+
editModelParams={{}}
127+
setEditModelParams={vi.fn()}
128+
handleSaveModel={vi.fn().mockResolvedValue(undefined)}
129+
/>,
130+
);
131+
132+
const backdrop = document.querySelector('[data-app-modal="true"]');
133+
expect(backdrop).not.toBeNull();
134+
135+
fireEvent.mouseDown(backdrop!);
136+
fireEvent.mouseUp(backdrop!);
137+
138+
expect(setEditingMetric).toHaveBeenCalledWith(null);
139+
});
140+
141+
it('closes the edit rule modal on escape', () => {
142+
const setEditingModel = vi.fn();
143+
144+
render(
145+
<HealthEditModals
146+
editingMetric={null}
147+
setEditingMetric={vi.fn()}
148+
editTargetValue=""
149+
setEditTargetValue={vi.fn()}
150+
editTargetDirection="AtOrAbove"
151+
setEditTargetDirection={vi.fn()}
152+
handleSaveTarget={vi.fn().mockResolvedValue(undefined)}
153+
editingModel={{
154+
id: 'rule-resting-heart-rate',
155+
code: 'resting_heart_rate',
156+
name: 'Resting Heart Rate',
157+
modelType: 'threshold',
158+
} as never}
159+
setEditingModel={setEditingModel}
160+
editModelParams={{ threshold: 60, direction: 'below', maxYearsAdded: 2 }}
161+
setEditModelParams={vi.fn()}
162+
handleSaveModel={vi.fn().mockResolvedValue(undefined)}
163+
/>,
164+
);
165+
166+
fireEvent.keyDown(window, { key: 'Escape' });
167+
168+
expect(setEditingModel).toHaveBeenCalledWith(null);
169+
});
68170
});

0 commit comments

Comments
 (0)