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:
Daniel Volz
2026-02-23 06:42:06 +01:00
committed by GitHub
parent 2aa6b1f406
commit ba36f67371
21 changed files with 337 additions and 163 deletions
+7 -2
View File
@@ -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}>
×
+2 -10
View File
@@ -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];
+7 -13
View File
@@ -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}>
+9 -2
View File
@@ -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}>
+7 -5
View File
@@ -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>
+18 -41
View File
@@ -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"
+26 -15
View File
@@ -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")}
+7 -2
View File
@@ -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}>
×
+9 -2
View File
@@ -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}>
×
+7 -2
View File
@@ -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"
+6 -11
View File
@@ -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>
)}
+7 -2
View File
@@ -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}>
×