fix: smooth mobile edit transition and align modal validation behavior (#286)
* fix: reliable Escape key close for all modals via useEscapeKey hook - Add useEscapeKey hook (document-level keydown listener) - Retrofit all 12 modal/overlay components to use it - Remove redundant overlay onKeyDown Escape handlers - Simplify modal-content onKeyDown to plain stopPropagation - Replace MedDetailModal's capture-phase useEffect with 3 useEscapeKey calls - Replace SharedSchedule's inline useEffect with useEscapeKey - Add mandatory modal rules to UI Consistency skill - All 777 frontend + 569 backend tests pass * fix: smooth mobile edit transition and align modal validation behavior * fix: keep overlay keydown non-closing for Enter key * fix: show mobile name error when validation already exists * fix: restore app-level escape priority handling * fix: prioritize schedule lightbox on Escape
This commit is contained in:
@@ -26,6 +26,16 @@ Use `medassist-frontend-polish` only after these guardrails are satisfied.
|
||||
- Avoid custom inline modal/button patterns that diverge from project design.
|
||||
- Prefer extending existing CSS classes/styles instead of introducing parallel styling systems.
|
||||
|
||||
### Modal requirements (non-negotiable)
|
||||
|
||||
Every modal/overlay **must** follow these rules:
|
||||
|
||||
1. **Escape key**: Call `useEscapeKey(active, onClose)` from `hooks/useEscapeKey`. This registers a document-level `keydown` listener that works regardless of focus. **Never** rely on `onKeyDown` on an overlay div — it only fires when the overlay has focus, which almost never happens.
|
||||
2. **Scroll lock**: Call `useScrollLock(active)` from `hooks/useScrollLock` if the modal is **not** already covered by App.tsx's centralized `useScrollLock` call. Page-local modals (e.g. `ReportModal`, `ExportModal`) must call it themselves.
|
||||
3. **Click-outside close**: The overlay div gets `onClick={onClose}`, and `.modal-content` gets `onClick={(e) => e.stopPropagation()}`.
|
||||
4. **Key event containment**: `.modal-content` gets `onKeyDown={(e) => { if (e.key !== "Escape") e.stopPropagation(); }}` — this prevents non-Escape keys from leaking out while still allowing Escape to propagate to the document-level handler.
|
||||
5. **Nested sub-modals** (e.g. edit-stock inside MedDetailModal): Use `useEscapeKey` with `{ capture: true }` so the innermost modal intercepts Escape before the parent's handler fires.
|
||||
|
||||
## Decision Heuristics
|
||||
|
||||
1. If an equivalent component exists, reuse it.
|
||||
|
||||
+122
-51
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AboutModal,
|
||||
Lightbox,
|
||||
@@ -114,6 +114,7 @@ function AppRouter() {
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// Get shared state from AppContext
|
||||
const ctx = useAppContext();
|
||||
const {
|
||||
@@ -189,6 +190,9 @@ function AppContent() {
|
||||
// Local-only state (not shared across components)
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [routeTransitionMaskActive, setRouteTransitionMaskActive] = useState(false);
|
||||
const routeTransitionMinEndRef = useRef(0);
|
||||
const routeTransitionFallbackTimerRef = useRef<number | null>(null);
|
||||
const closeProfile = useCallback(() => {
|
||||
if (showProfile) {
|
||||
window.history.back();
|
||||
@@ -204,55 +208,6 @@ function AppContent() {
|
||||
// Get centralized stockThresholds from context
|
||||
const { stockThresholds } = ctx;
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
// Close modals in order of priority (topmost first)
|
||||
if (scheduleLightboxImage) {
|
||||
closeScheduleLightbox();
|
||||
} else if (showImageLightbox) {
|
||||
closeImageLightbox();
|
||||
} else if (showEditStockModal) {
|
||||
closeEditStockModal();
|
||||
} else if (showRefillModal) {
|
||||
closeRefillModal();
|
||||
} else if (showShareDialog) {
|
||||
closeShareDialog();
|
||||
} else if (showAbout) {
|
||||
closeAbout();
|
||||
} else if (showProfile) {
|
||||
closeProfile();
|
||||
} else if (selectedUser) {
|
||||
closeUserFilter();
|
||||
} else if (selectedMed) {
|
||||
closeMedDetail();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [
|
||||
selectedMed,
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
selectedUser,
|
||||
showProfile,
|
||||
showAbout,
|
||||
showShareDialog,
|
||||
showRefillModal,
|
||||
showEditStockModal,
|
||||
closeAbout,
|
||||
closeEditStockModal,
|
||||
closeImageLightbox,
|
||||
closeMedDetail,
|
||||
closeProfile,
|
||||
closeRefillModal,
|
||||
closeScheduleLightbox,
|
||||
closeShareDialog,
|
||||
closeUserFilter,
|
||||
]);
|
||||
|
||||
// Handle browser back button to close modals (in priority order)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
@@ -341,6 +296,72 @@ function AppContent() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Global Escape handling in priority order.
|
||||
// This keeps behavior consistent even when child modals are mocked in tests.
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
|
||||
if (scheduleLightboxImage) {
|
||||
closeScheduleLightbox();
|
||||
return;
|
||||
}
|
||||
if (showImageLightbox) {
|
||||
closeImageLightbox();
|
||||
return;
|
||||
}
|
||||
if (showEditStockModal) {
|
||||
closeEditStockModal();
|
||||
return;
|
||||
}
|
||||
if (showRefillModal) {
|
||||
closeRefillModal();
|
||||
return;
|
||||
}
|
||||
if (showShareDialog) {
|
||||
closeShareDialog();
|
||||
return;
|
||||
}
|
||||
if (showAbout) {
|
||||
closeAbout();
|
||||
return;
|
||||
}
|
||||
if (showProfile) {
|
||||
closeProfile();
|
||||
return;
|
||||
}
|
||||
if (selectedUser) {
|
||||
closeUserFilter();
|
||||
return;
|
||||
}
|
||||
if (selectedMed) {
|
||||
closeMedDetail();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
showEditStockModal,
|
||||
showRefillModal,
|
||||
showShareDialog,
|
||||
showAbout,
|
||||
showProfile,
|
||||
selectedUser,
|
||||
selectedMed,
|
||||
closeImageLightbox,
|
||||
closeScheduleLightbox,
|
||||
closeEditStockModal,
|
||||
closeRefillModal,
|
||||
closeShareDialog,
|
||||
closeAbout,
|
||||
closeProfile,
|
||||
closeUserFilter,
|
||||
closeMedDetail,
|
||||
]);
|
||||
|
||||
// Prevent background scroll when any modal is open
|
||||
useScrollLock(
|
||||
!!(
|
||||
@@ -383,9 +404,57 @@ function AppContent() {
|
||||
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeTransitionMaskActive) return;
|
||||
if (location.pathname !== "/medications") return;
|
||||
|
||||
const hasEditMedIdParam = new URLSearchParams(location.search).has("editMedId");
|
||||
if (hasEditMedIdParam) return;
|
||||
|
||||
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
||||
const timer = window.setTimeout(() => setRouteTransitionMaskActive(false), remaining);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [location.pathname, location.search, routeTransitionMaskActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEditTransitionReady = () => {
|
||||
if (!routeTransitionMaskActive) return;
|
||||
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
||||
window.setTimeout(() => {
|
||||
setRouteTransitionMaskActive(false);
|
||||
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||
routeTransitionFallbackTimerRef.current = null;
|
||||
}
|
||||
}, remaining);
|
||||
};
|
||||
|
||||
window.addEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
||||
return () => {
|
||||
window.removeEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
||||
};
|
||||
}, [routeTransitionMaskActive]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenMedicationEdit = () => {
|
||||
if (!selectedMed) return;
|
||||
const medId = selectedMed.id;
|
||||
routeTransitionMinEndRef.current = performance.now() + 80;
|
||||
setRouteTransitionMaskActive(true);
|
||||
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||
}
|
||||
routeTransitionFallbackTimerRef.current = window.setTimeout(() => {
|
||||
setRouteTransitionMaskActive(false);
|
||||
routeTransitionFallbackTimerRef.current = null;
|
||||
}, 700);
|
||||
setShowImageLightbox(false);
|
||||
setShowRefillModal(false);
|
||||
setShowEditStockModal(false);
|
||||
@@ -508,6 +577,8 @@ function AppContent() {
|
||||
{scheduleLightboxImage && (
|
||||
<Lightbox src={scheduleLightboxImage} alt="Medication" onClose={closeScheduleLightbox} />
|
||||
)}
|
||||
|
||||
<div className={`route-transition-mask${routeTransitionMaskActive ? " active" : ""}`} aria-hidden="true" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FRONTEND_VERSION, GITHUB_URL } from "../App";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
interface UpdateCheckResult {
|
||||
status: "up-to-date" | "update-available" | "error";
|
||||
@@ -17,6 +18,8 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
|
||||
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
// Reset check result when modal opens so stale results are never shown
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -55,13 +58,15 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content about-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: auth refresh callbacks intentionally coordinate via refs/guards */
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { log } from "../utils/logger";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
@@ -581,16 +582,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
useEscapeKey(!!onClose, onClose ?? (() => {}));
|
||||
|
||||
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// ConfirmModal Component - Simple confirmation dialog
|
||||
// =============================================================================
|
||||
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
@@ -27,29 +28,22 @@ export function ConfirmModal({
|
||||
confirmVariant = "primary",
|
||||
overlayClassName,
|
||||
}: ConfirmModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
useEscapeKey(true, onCancel);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`}
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content confirm-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
style={{ maxWidth: "450px" }}
|
||||
>
|
||||
<button className="modal-close" onClick={onCancel}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -10,6 +12,9 @@ interface ExportModalProps {
|
||||
export default function ExportModal({ isOpen, onClose, onExport, exporting }: ExportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useScrollLock(isOpen);
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -17,13 +22,15 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
style={{ maxWidth: "450px" }}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// =============================================================================
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
export interface LightboxProps {
|
||||
src: string;
|
||||
@@ -11,6 +12,8 @@ export interface LightboxProps {
|
||||
}
|
||||
|
||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
useEscapeKey(true, onClose);
|
||||
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.target === e.currentTarget) {
|
||||
@@ -23,10 +26,7 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
className="lightbox-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="lightbox-container">
|
||||
@@ -38,7 +38,9 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
alt={alt}
|
||||
className="lightbox-image"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Bell, Calendar, ClipboardList, FilePenLine, Minus, NotebookPen, Pencil,
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lightbox, MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks";
|
||||
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||
@@ -155,21 +156,11 @@ export function MedDetailModal({
|
||||
}
|
||||
}, [showEditStockModal, editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEditStockModal) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
if (typeof event.stopImmediatePropagation === "function") {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
event.preventDefault();
|
||||
onCloseEditStockModal();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape, true);
|
||||
return () => document.removeEventListener("keydown", handleEscape, true);
|
||||
}, [showEditStockModal, onCloseEditStockModal]);
|
||||
// Escape key: only one handler is active at a time (sub-modal states are mutually exclusive).
|
||||
// Lightbox has its own useEscapeKey internally.
|
||||
useEscapeKey(!showEditStockModal && !showImageLightbox && !showRefillModal, onClose);
|
||||
useEscapeKey(showEditStockModal, onCloseEditStockModal);
|
||||
useEscapeKey(showRefillModal, onCloseRefillModal);
|
||||
|
||||
useEffect(() => {
|
||||
if (showEditStockModal) return;
|
||||
@@ -369,21 +360,15 @@ export function MedDetailModal({
|
||||
onCloseEditStockModal();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onCloseEditStockModal();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content edit-stock-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCloseEditStockModal();
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -648,11 +633,7 @@ export function MedDetailModal({
|
||||
className="modal-overlay med-detail-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (showEditStockModal || showImageLightbox || showRefillModal) return;
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -660,14 +641,9 @@ export function MedDetailModal({
|
||||
ref={detailModalRef}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -701,8 +677,8 @@ export function MedDetailModal({
|
||||
<span className="med-taken-by">
|
||||
{t("modal.for")}{" "}
|
||||
{selectedMed.takenBy.map((person, index) => (
|
||||
<span key={person}>
|
||||
{index > 0 && ", "}
|
||||
<span key={person} style={{ whiteSpace: "nowrap" }}>
|
||||
{index > 0 && (index === selectedMed.takenBy.length - 1 ? ` ${t("common.and")} ` : ", ")}
|
||||
{person}
|
||||
{selectedMed.intakes?.some(
|
||||
(intake) => intake.takenBy === person && intake.intakeRemindersEnabled
|
||||
@@ -1053,14 +1029,15 @@ export function MedDetailModal({
|
||||
onCloseRefillModal();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onCloseRefillModal();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content refill-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||
import { DOSE_UNITS } from "../types";
|
||||
@@ -119,24 +120,24 @@ export function MobileEditModal({
|
||||
const swipeAxisRef = useRef<"x" | "y" | null>(null);
|
||||
const [swipeDeltaX, setSwipeDeltaX] = useState(0);
|
||||
const [isHorizontalSwiping, setIsHorizontalSwiping] = useState(false);
|
||||
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||
const activeTabIndexRef = useRef(0);
|
||||
|
||||
// Reset tab when modal opens
|
||||
useEffect(() => {
|
||||
if (show) setActiveTab("general");
|
||||
if (show) {
|
||||
setActiveTab("general");
|
||||
setShowNameValidation(false);
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
if (show && (hasValidationErrors || !!fieldErrors.name)) {
|
||||
setShowNameValidation(true);
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [show, onClose]);
|
||||
}, [show, hasValidationErrors, fieldErrors.name]);
|
||||
|
||||
useEscapeKey(show, onClose);
|
||||
|
||||
// Lock background scroll while modal is open.
|
||||
useScrollLock(show);
|
||||
@@ -260,13 +261,15 @@ export function MobileEditModal({
|
||||
className="modal-overlay mobile-edit-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content edit-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="edit-modal-header">
|
||||
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
||||
@@ -343,16 +346,24 @@ export function MobileEditModal({
|
||||
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
|
||||
<label
|
||||
className={`full ${!readOnlyMode && showNameValidation && fieldErrors.name ? "has-error" : ""}`}
|
||||
>
|
||||
{t("form.commercialName")}
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setShowNameValidation(true);
|
||||
onFormChange({ ...form, name: e.target.value });
|
||||
}}
|
||||
onBlur={() => setShowNameValidation(true)}
|
||||
placeholder={t("form.placeholders.commercial")}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required={!readOnlyMode}
|
||||
/>
|
||||
{!readOnlyMode && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
{!readOnlyMode && showNameValidation && fieldErrors.name && (
|
||||
<span className="field-error">{fieldErrors.name}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
||||
{t("form.genericName")}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { UserProfile } from "./Auth";
|
||||
|
||||
interface ProfileModalProps {
|
||||
@@ -6,6 +7,8 @@ interface ProfileModalProps {
|
||||
}
|
||||
|
||||
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -13,13 +16,15 @@ export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content profile-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
import type { Medication } from "../types";
|
||||
import { getPackageSize } from "../types";
|
||||
import { MedicationAvatar } from "./MedicationAvatar";
|
||||
@@ -31,6 +33,9 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
|
||||
|
||||
useScrollLock(isOpen);
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
// Collect all unique "taken by" people across all medications
|
||||
const allPeople = useMemo(() => {
|
||||
const people = new Set<string>();
|
||||
@@ -138,13 +143,15 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content report-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Check, Copy, Link2, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
export interface ShareDialogProps {
|
||||
show: boolean;
|
||||
@@ -43,6 +44,8 @@ export function ShareDialog({
|
||||
const closeLabel = t("common.close");
|
||||
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||
|
||||
useEscapeKey(show, onClose);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
@@ -50,13 +53,15 @@ export function ShareDialog({
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content share-dialog-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useEscapeKey } from "../hooks";
|
||||
import type { ExpiredLinkData, SharedScheduleData } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
@@ -151,15 +152,7 @@ export function SharedSchedule() {
|
||||
}
|
||||
|
||||
// Close lightbox on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && lightboxImage) {
|
||||
closeLightbox();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [lightboxImage, closeLightbox]);
|
||||
useEscapeKey(!!lightboxImage, closeLightbox);
|
||||
|
||||
// Handle browser back button to close lightbox
|
||||
useEffect(() => {
|
||||
@@ -1283,7 +1276,7 @@ export function SharedSchedule() {
|
||||
className="lightbox-overlay"
|
||||
onClick={closeLightbox}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") closeLightbox();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="lightbox-close" onClick={closeLightbox}>
|
||||
@@ -1294,7 +1287,9 @@ export function SharedSchedule() {
|
||||
alt={lightboxImage.name}
|
||||
className="lightbox-image"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber } from "../utils";
|
||||
@@ -31,6 +32,8 @@ export function UserFilterModal({
|
||||
}: UserFilterModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
useEscapeKey(!!selectedUser, onClose);
|
||||
|
||||
if (!selectedUser) return null;
|
||||
|
||||
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
||||
@@ -40,13 +43,15 @@ export function UserFilterModal({
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content user-meds-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
|
||||
@@ -4,6 +4,7 @@ export type { UseCollapsedDaysReturn } from "./useCollapsedDays";
|
||||
export { useCollapsedDays } from "./useCollapsedDays";
|
||||
export type { UseDosesReturn } from "./useDoses";
|
||||
export { useDoses } from "./useDoses";
|
||||
export { useEscapeKey } from "./useEscapeKey";
|
||||
export type { UseMedicationFormReturn } from "./useMedicationForm";
|
||||
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
||||
export type { UseMedicationsReturn } from "./useMedications";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Close a modal/overlay when the user presses Escape.
|
||||
*
|
||||
* Registers a document-level `keydown` listener so it works regardless
|
||||
* of which element has focus. Every modal **must** use this hook —
|
||||
* relying on `onKeyDown` on overlay divs is unreliable because those
|
||||
* handlers only fire when the overlay itself (or a descendant) has focus.
|
||||
*
|
||||
* @param active – whether the modal is currently open
|
||||
* @param onClose – callback to close the modal
|
||||
* @param options.capture – use capture phase (default: false).
|
||||
* Set to `true` for nested sub-modals that must intercept Escape
|
||||
* before a parent's handler fires.
|
||||
*/
|
||||
export function useEscapeKey(active: boolean, onClose: () => void, options?: { capture?: boolean }): void {
|
||||
const capture = options?.capture ?? false;
|
||||
const activeRef = useRef(active);
|
||||
const onCloseRef = useRef(onClose);
|
||||
|
||||
// Keep refs in sync without re-registering the listener
|
||||
activeRef.current = active;
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && activeRef.current) {
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown, capture);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown, capture);
|
||||
}, [active, capture]);
|
||||
}
|
||||
@@ -353,6 +353,7 @@
|
||||
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
||||
"automaticTaken": "Automatisch eingenommen",
|
||||
"hasNotes": "Hat Notizen",
|
||||
"hasPrescription": "Rezeptverfolgung aktiviert",
|
||||
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
|
||||
"lightMode": "Zum hellen Modus wechseln",
|
||||
"darkMode": "Zum dunklen Modus wechseln"
|
||||
@@ -452,6 +453,7 @@
|
||||
"loose": "lose",
|
||||
"none": "Kein",
|
||||
"daily": "täglich",
|
||||
"and": "und",
|
||||
"everyNDays": "alle {{count}} Tage",
|
||||
"day": "Tag",
|
||||
"days": "Tage",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"intakeReminders": "Intake reminders enabled",
|
||||
"automaticTaken": "Automatically taken",
|
||||
"hasNotes": "Has notes",
|
||||
"hasPrescription": "Prescription tracking enabled",
|
||||
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
|
||||
"lightMode": "Switch to light mode",
|
||||
"darkMode": "Switch to dark mode"
|
||||
@@ -452,6 +453,7 @@
|
||||
"loose": "loose",
|
||||
"none": "None",
|
||||
"daily": "daily",
|
||||
"and": "and",
|
||||
"everyNDays": "every {{count}} days",
|
||||
"day": "day",
|
||||
"days": "days",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
|
||||
import { Bell, NotebookPen, Share2 } from "lucide-react";
|
||||
import { Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
@@ -538,6 +538,17 @@ export function DashboardPage() {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{med?.prescriptionEnabled && (
|
||||
<>
|
||||
{" "}
|
||||
<span
|
||||
className="prescription-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.hasPrescription")}
|
||||
>
|
||||
<ClipboardList size={13} aria-hidden="true" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{med?.takenBy && med.takenBy.length > 0 && (
|
||||
<span className="med-taken-by-line">
|
||||
|
||||
@@ -76,12 +76,14 @@ export function MedicationsPage() {
|
||||
useUnsavedChangesWarning(formChanged);
|
||||
|
||||
// View mode: grid (default) or form (edit/new)
|
||||
const [viewMode, setViewMode] = useState<"grid" | "form">("grid");
|
||||
// If navigating in with editMedId, suppress rendering until the edit form is ready
|
||||
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
|
||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||
|
||||
// Mobile modal state (declared early because it's used in useEffect below)
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(pendingEditTransition && window.innerWidth <= 768);
|
||||
const showEditModalRef = useRef(false);
|
||||
useEffect(() => {
|
||||
showEditModalRef.current = showEditModal;
|
||||
@@ -705,6 +707,8 @@ export function MedicationsPage() {
|
||||
setActiveTab("general");
|
||||
startEdit(medicationToEdit, openEditModal);
|
||||
setViewMode("form");
|
||||
setPendingEditTransition(false);
|
||||
window.dispatchEvent(new Event("medassist:edit-transition-ready"));
|
||||
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete("editMedId");
|
||||
@@ -716,6 +720,11 @@ export function MedicationsPage() {
|
||||
return allMeds.find((med) => med.id === editingId) ?? null;
|
||||
}, [allMeds, editingId]);
|
||||
|
||||
// While navigating from detail modal to edit, render nothing until form is populated
|
||||
if (pendingEditTransition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`med-grid-wrapper${viewMode === "form" ? " desktop-edit-open" : ""}`}>
|
||||
{/* ── Grid View: always visible medication cards ── */}
|
||||
|
||||
+29
-2
@@ -108,6 +108,22 @@ body.modal-open {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.route-transition-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--bg-primary);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 140ms ease-out;
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
.route-transition-mask.active {
|
||||
transition: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, rgba(67, 106, 255, 0.08), rgba(115, 195, 255, 0.06));
|
||||
border: 1px solid var(--border-primary);
|
||||
@@ -4397,6 +4413,7 @@ button.has-validation-error {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.taken-by-badge {
|
||||
@@ -5012,7 +5029,8 @@ button.has-validation-error {
|
||||
|
||||
/* Reminder icon indicator */
|
||||
.reminder-icon.info-tooltip,
|
||||
.notes-icon.info-tooltip {
|
||||
.notes-icon.info-tooltip,
|
||||
.prescription-icon.info-tooltip {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0 !important;
|
||||
@@ -5027,6 +5045,10 @@ button.has-validation-error {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.prescription-icon.info-tooltip {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.reminder-icon.info-tooltip,
|
||||
.blister-reminder-icon {
|
||||
color: var(--warning);
|
||||
@@ -5041,8 +5063,13 @@ button.has-validation-error {
|
||||
color: #1d4ed8; /* darker blue — strong contrast on light backgrounds */
|
||||
}
|
||||
|
||||
[data-theme="light"] .prescription-icon.info-tooltip {
|
||||
color: #047857; /* dark emerald — strong contrast on light backgrounds */
|
||||
}
|
||||
|
||||
.reminder-icon.info-tooltip:hover,
|
||||
.notes-icon.info-tooltip:hover {
|
||||
.notes-icon.info-tooltip:hover,
|
||||
.prescription-icon.info-tooltip:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user