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:
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }, "");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user