fix: stabilize dashboard modal and image click behavior (#267)

* feat: make medication names clickable in Dashboard dose schedule

Add click handlers to med-name-stack divs in all three dose schedule
sections (past, current/overdue, future) on DashboardPage, opening the
MedDetail modal on click.

Add early-return guards to all four modal openers in AppContext
(openMedDetail, openImageLightbox, openScheduleLightbox, openUserFilter)
to prevent duplicate pushState entries on double-click, which caused
unexpected navigation to the Medications page.

Closes #266

* fix: stabilize dashboard modal and image click handling

* fix: close medication detail on first backdrop click
This commit is contained in:
Daniel Volz
2026-02-22 10:50:58 +01:00
committed by GitHub
parent 088a6c1a05
commit 9a2d42b8b9
7 changed files with 147 additions and 39 deletions
+1 -12
View File
@@ -3,7 +3,6 @@
// =============================================================================
import type { MouseEvent } from "react";
import { useEffect } from "react";
export interface LightboxProps {
src: string;
@@ -12,17 +11,6 @@ export interface LightboxProps {
}
export function Lightbox({ src, alt, onClose }: LightboxProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function handleOverlayClick(e: MouseEvent) {
e.stopPropagation();
if (e.target === e.currentTarget) {
@@ -36,6 +24,7 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
onClick={handleOverlayClick}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
}}
+5 -2
View File
@@ -648,8 +648,11 @@ export function MedDetailModal({
className="modal-overlay med-detail-overlay"
onClick={onClose}
onKeyDown={(e) => {
if (showEditStockModal) return;
if (e.key === "Escape") onClose();
if (showEditStockModal || showImageLightbox || showRefillModal) return;
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
}}
>
<div
+87 -19
View File
@@ -1,5 +1,5 @@
import type React from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
@@ -253,9 +253,32 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// Modal state
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
const selectedMedIdRef = useRef<number | null>(null);
const medDetailOpenedAtRef = useRef(0);
const medDetailCloseInFlightRef = useRef(false);
useEffect(() => {
selectedMedIdRef.current = selectedMed?.id ?? null;
if (!selectedMed) {
medDetailCloseInFlightRef.current = false;
}
}, [selectedMed]);
const [showImageLightbox, setShowImageLightbox] = useState(false);
const imageLightboxOpenedAtRef = useRef(0);
const imageLightboxCloseInFlightRef = useRef(false);
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
const scheduleLightboxOpenedAtRef = useRef(0);
const scheduleLightboxCloseInFlightRef = useRef(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
useEffect(() => {
if (!showImageLightbox) {
imageLightboxCloseInFlightRef.current = false;
}
}, [showImageLightbox]);
useEffect(() => {
if (!scheduleLightboxImage) {
scheduleLightboxCloseInFlightRef.current = false;
}
}, [scheduleLightboxImage]);
// Export/Import state
const [exporting, setExporting] = useState(false);
@@ -467,6 +490,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// Modal helpers with browser history support
const openMedDetail = useCallback(
(med: Medication) => {
if (selectedMedIdRef.current === med.id) return;
selectedMedIdRef.current = med.id;
medDetailOpenedAtRef.current = Date.now();
medDetailCloseInFlightRef.current = false;
setSelectedMed(med);
refill.setRefillHistoryExpanded(false);
refill.loadRefillHistory(med.id);
@@ -476,37 +503,78 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
);
const closeMedDetail = useCallback(() => {
if (selectedMed) {
window.history.back();
if (!selectedMed || medDetailCloseInFlightRef.current) return;
// Ignore ultra-fast close requests caused by rapid double-click races
if (Date.now() - medDetailOpenedAtRef.current < 320) return;
const currentState = window.history.state as { modal?: string } | null;
if (currentState?.modal !== "medDetail") {
// State already popped by another event: close locally without another back step.
selectedMedIdRef.current = null;
setSelectedMed(null);
return;
}
medDetailCloseInFlightRef.current = true;
window.history.back();
}, [selectedMed]);
const openImageLightbox = useCallback(() => {
if (showImageLightbox) return;
imageLightboxOpenedAtRef.current = Date.now();
imageLightboxCloseInFlightRef.current = false;
setShowImageLightbox(true);
window.history.pushState({ modal: "imageLightbox" }, "");
}, []);
const closeImageLightbox = useCallback(() => {
if (showImageLightbox) {
window.history.back();
}
}, [showImageLightbox]);
const openScheduleLightbox = useCallback((imageUrl: string) => {
setScheduleLightboxImage(imageUrl);
window.history.pushState({ modal: "scheduleLightbox" }, "");
}, []);
const closeImageLightbox = useCallback(() => {
if (!showImageLightbox || imageLightboxCloseInFlightRef.current) return;
if (Date.now() - imageLightboxOpenedAtRef.current < 320) return;
const currentState = window.history.state as { modal?: string } | null;
if (currentState?.modal !== "imageLightbox") {
setShowImageLightbox(false);
return;
}
imageLightboxCloseInFlightRef.current = true;
window.history.back();
}, [showImageLightbox]);
const openScheduleLightbox = useCallback(
(imageUrl: string) => {
if (scheduleLightboxImage) return;
scheduleLightboxOpenedAtRef.current = Date.now();
scheduleLightboxCloseInFlightRef.current = false;
setScheduleLightboxImage(imageUrl);
window.history.pushState({ modal: "scheduleLightbox" }, "");
},
[scheduleLightboxImage]
);
const closeScheduleLightbox = useCallback(() => {
if (scheduleLightboxImage) {
window.history.back();
if (!scheduleLightboxImage || scheduleLightboxCloseInFlightRef.current) return;
if (Date.now() - scheduleLightboxOpenedAtRef.current < 320) return;
const currentState = window.history.state as { modal?: string } | null;
if (currentState?.modal !== "scheduleLightbox") {
setScheduleLightboxImage(null);
return;
}
scheduleLightboxCloseInFlightRef.current = true;
window.history.back();
}, [scheduleLightboxImage]);
const openUserFilter = useCallback((person: string) => {
setSelectedUser(person);
window.history.pushState({ modal: "userFilter", person }, "");
}, []);
const openUserFilter = useCallback(
(person: string) => {
if (selectedUser === person) return;
setSelectedUser(person);
window.history.pushState({ modal: "userFilter", person }, "");
},
[selectedUser]
);
const closeUserFilter = useCallback(() => {
if (selectedUser) {
+42 -4
View File
@@ -512,7 +512,21 @@ export function DashboardPage() {
>
<span data-label={t("table.name")} className="cell-with-avatar">
<span className="med-name-line">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span
className={med?.imageUrl ? "med-avatar-clickable" : undefined}
onClick={(e) => {
e.stopPropagation();
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}
}}
>
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
</span>
<span className="med-name-block-dash">
<span className="med-name-text">
{row.name}
@@ -730,7 +744,15 @@ export function DashboardPage() {
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<div className="med-name-stack">
<div
className="med-name-stack clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
@@ -982,7 +1004,15 @@ export function DashboardPage() {
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<div className="med-name-stack">
<div
className="med-name-stack clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
@@ -1205,7 +1235,15 @@ export function DashboardPage() {
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<div className="med-name-stack">
<div
className="med-name-stack clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
+6
View File
@@ -74,6 +74,10 @@ export function MedicationsPage() {
// Mobile modal state (declared early because it's used in useEffect below)
const [showEditModal, setShowEditModal] = useState(false);
const showEditModalRef = useRef(false);
useEffect(() => {
showEditModalRef.current = showEditModal;
}, [showEditModal]);
const processedEditMedIdRef = useRef<string | null>(null);
const hasDesktopFormHistoryState = useRef(false);
@@ -199,6 +203,8 @@ export function MedicationsPage() {
// Open mobile edit modal
function openEditModal() {
if (showEditModalRef.current) return;
showEditModalRef.current = true;
setShowEditModal(true);
window.history.pushState({ modal: "edit" }, "");
}
+3
View File
@@ -2212,6 +2212,9 @@ button.has-validation-error {
.time-main .med-name span.clickable {
cursor: pointer;
}
.time-main .med-name .med-name-stack.clickable {
cursor: pointer;
}
.time-main .med-name span.clickable:hover .med-avatar {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
@@ -48,9 +48,10 @@ describe("Lightbox", () => {
it("calls onClose when Escape key is pressed", () => {
const onClose = vi.fn();
render(<Lightbox {...defaultProps} onClose={onClose} />);
const { container } = render(<Lightbox {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
const overlay = container.querySelector(".lightbox-overlay");
fireEvent.keyDown(overlay!, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});