feat: close modals with browser back button on mobile (#257)
* feat: close modals with browser back button on mobile Create reusable useModalHistory hook that pushes history state when a modal opens and listens for popstate to close it. Apply to ReportModal, ClearMissedConfirm, ExportModal, ImportConfirm, and all modals using ConfirmModal/ShareDialog/Auth/ExportModal base components. Escape key handling was already in place for desktop. Closes #253 * fix: update tests for renamed button labels and missing useModalHistory mock
This commit is contained in:
@@ -756,7 +756,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
<div className="profile-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
{t("common.close", "Close")}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
||||
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function ConfirmModal({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
className="modal-content confirm-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: "450px" }}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
||||
</div>
|
||||
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("exportImport.cancelButton")}
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -256,7 +256,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
{/* Actions */}
|
||||
<div className="report-actions">
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
{t("common.close")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -145,7 +145,7 @@ export function ShareDialog({
|
||||
|
||||
<div className="share-dialog-footer">
|
||||
<button className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
{t("common.close")}
|
||||
</button>
|
||||
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||
|
||||
@@ -8,6 +8,7 @@ export type { UseMedicationFormReturn } from "./useMedicationForm";
|
||||
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
||||
export type { UseMedicationsReturn } from "./useMedications";
|
||||
export { useMedications } from "./useMedications";
|
||||
export { useModalHistory } from "./useModalHistory";
|
||||
export type { UseRefillReturn } from "./useRefill";
|
||||
export { useRefill } from "./useRefill";
|
||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Push a history entry when a modal opens so the browser back button closes it.
|
||||
* On popstate (back), calls `onClose` to dismiss the modal.
|
||||
*/
|
||||
export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () => void) {
|
||||
const pushedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
window.history.pushState({ modal: modalKey }, "");
|
||||
pushedRef.current = true;
|
||||
} else if (pushedRef.current) {
|
||||
pushedRef.current = false;
|
||||
}
|
||||
}, [isOpen, modalKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePopState = () => {
|
||||
if (pushedRef.current) {
|
||||
pushedRef.current = false;
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [isOpen, onClose]);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { useModalHistory } from "../hooks";
|
||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||
import {
|
||||
@@ -75,6 +76,8 @@ export function DashboardPage() {
|
||||
loadSettings,
|
||||
} = useAppContext();
|
||||
|
||||
useModalHistory(showClearMissedConfirm, "clearMissed", () => setShowClearMissedConfirm(false));
|
||||
|
||||
// Get structured reminder data
|
||||
const reminderData = getReminderStatusData(
|
||||
settings.reminderDaysBefore,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "react-router-dom";
|
||||
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal, ReportModal } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
|
||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||
import type { DoseUnit, Medication } from "../types";
|
||||
import { DOSE_UNITS, FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
|
||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
@@ -125,6 +125,7 @@ export function MedicationsPage() {
|
||||
const [showObsolete, setShowObsolete] = useState(true);
|
||||
const [readOnlyView, setReadOnlyView] = useState(false);
|
||||
const [showReportModal, setShowReportModal] = useState(false);
|
||||
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
|
||||
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -463,6 +464,20 @@ export function MedicationsPage() {
|
||||
|
||||
// Reset form after successful save
|
||||
if (!editingId) {
|
||||
const shouldCloseMobileModal = showEditModal && window.innerWidth <= 768;
|
||||
if (shouldCloseMobileModal) {
|
||||
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||
closeConfirmedRef.current = true;
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
setViewMode("grid");
|
||||
resetForm();
|
||||
window.history.back();
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
setViewMode("grid");
|
||||
} else {
|
||||
@@ -480,6 +495,8 @@ export function MedicationsPage() {
|
||||
// Handle browser back button for modals and unsaved changes
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
|
||||
|
||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||
if (showObsoleteConfirm) {
|
||||
setShowObsoleteConfirm(false);
|
||||
@@ -497,6 +514,11 @@ export function MedicationsPage() {
|
||||
// If close was already confirmed programmatically, allow navigation
|
||||
if (closeConfirmedRef.current) {
|
||||
closeConfirmedRef.current = false;
|
||||
if (currentEditMedId) {
|
||||
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||
processedEditMedIdRef.current = currentEditMedId;
|
||||
clearEditMedIdParam();
|
||||
}
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
@@ -515,6 +537,10 @@ export function MedicationsPage() {
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (currentEditMedId) {
|
||||
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||
processedEditMedIdRef.current = currentEditMedId;
|
||||
}
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-content.confirm-modal {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 450px);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.modal-content.confirm-modal {
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
max-height: min(85dvh, 85vh);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -556,7 +556,7 @@ describe("UserProfile", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelBtn = screen.getByText(/common\.cancel/i);
|
||||
const cancelBtn = screen.getByText(/common\.close/i);
|
||||
fireEvent.click(cancelBtn);
|
||||
});
|
||||
|
||||
|
||||
@@ -70,12 +70,12 @@ describe("ExportModal", () => {
|
||||
|
||||
it("renders cancel button", () => {
|
||||
render(<ExportModal {...defaultProps} />);
|
||||
expect(screen.getByText(/exportImport\.cancelButton/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/common\.close/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when cancel button is clicked", () => {
|
||||
render(<ExportModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText(/exportImport\.cancelButton/i));
|
||||
fireEvent.click(screen.getByText(/common\.close/i));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("ReportModal", () => {
|
||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||
|
||||
expect(screen.getByText(/report\.title/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /common\.cancel/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /common\.close/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ describe("ShareDialog", () => {
|
||||
|
||||
it("calls onClose when close button is clicked", () => {
|
||||
render(<ShareDialog {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /common\.close/i }));
|
||||
const closeButtons = screen.getAllByRole("button", { name: /common\.close/i });
|
||||
fireEvent.click(closeButtons[closeButtons.length - 1]);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ const fetchMock = vi.fn();
|
||||
vi.mock("../../hooks", () => ({
|
||||
useMedicationForm: () => mockFormHookValue,
|
||||
useUnsavedChangesWarning: () => ({}),
|
||||
useModalHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../context", () => ({
|
||||
|
||||
Reference in New Issue
Block a user