diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index 9d0f560..7e71b0a 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -3,8 +3,8 @@ * Handles new medication creation and editing existing medications */ -import { Minus, Plus, Trash2 } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Bell, Minus, Plus, Trash2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types"; import { DOSE_UNITS } from "../types"; @@ -19,6 +19,9 @@ const FIELD_LIMITS = { notes: { max: 1000 }, }; +const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const; +type MobileTab = (typeof MOBILE_TAB_ORDER)[number]; + export interface MobileEditModalProps { show: boolean; editingId: number | null; @@ -103,7 +106,15 @@ export function MobileEditModal({ onSaveMedication, }: MobileEditModalProps) { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general"); + const [activeTab, setActiveTab] = useState("general"); + const fieldsetRef = useRef(null); + const tabStripRef = useRef(null); + const tabViewportRef = useRef(null); + const swipeStartRef = useRef<{ x: number; y: number } | null>(null); + const swipeAxisRef = useRef<"x" | "y" | null>(null); + const [swipeDeltaX, setSwipeDeltaX] = useState(0); + const [isHorizontalSwiping, setIsHorizontalSwiping] = useState(false); + const activeTabIndexRef = useRef(0); // Reset tab when modal opens useEffect(() => { @@ -125,16 +136,166 @@ export function MobileEditModal({ // Lock background scroll while modal is open. useEffect(() => { if (!show) return; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; + const html = document.documentElement; + const body = document.body; + const scrollY = window.scrollY; + + const hadHtmlModalClass = html.classList.contains("modal-open"); + const hadBodyModalClass = body.classList.contains("modal-open"); + + const previousHtmlOverflow = html.style.overflow; + const previousHtmlOverscrollBehavior = html.style.overscrollBehavior; + const previousBodyOverflow = body.style.overflow; + const previousBodyPosition = body.style.position; + const previousBodyTop = body.style.top; + const previousBodyLeft = body.style.left; + const previousBodyRight = body.style.right; + const previousBodyWidth = body.style.width; + const previousBodyOverscrollBehavior = body.style.overscrollBehavior; + + html.classList.add("modal-open"); + body.classList.add("modal-open"); + html.style.overflow = "hidden"; + html.style.overscrollBehavior = "none"; + body.style.overflow = "hidden"; + body.style.position = "fixed"; + body.style.top = `-${scrollY}px`; + body.style.left = "0"; + body.style.right = "0"; + body.style.width = "100%"; + body.style.overscrollBehavior = "none"; + return () => { - document.body.style.overflow = previousOverflow; + if (!hadHtmlModalClass) html.classList.remove("modal-open"); + if (!hadBodyModalClass) body.classList.remove("modal-open"); + + html.style.overflow = previousHtmlOverflow; + html.style.overscrollBehavior = previousHtmlOverscrollBehavior; + body.style.overflow = previousBodyOverflow; + body.style.position = previousBodyPosition; + body.style.top = previousBodyTop; + body.style.left = previousBodyLeft; + body.style.right = previousBodyRight; + body.style.width = previousBodyWidth; + body.style.overscrollBehavior = previousBodyOverscrollBehavior; + + window.scrollTo(0, scrollY); + }; + }, [show]); + + // Keep activeTabIndex ref in sync for native listeners + const activeTabIndex = MOBILE_TAB_ORDER.indexOf(activeTab); + activeTabIndexRef.current = activeTabIndex; + + // Auto-scroll tab strip to keep active tab visible + useEffect(() => { + const strip = tabStripRef.current; + if (!strip) return; + const btn = strip.children[activeTabIndex] as HTMLElement | undefined; + if (btn) btn.scrollIntoView?.({ behavior: "smooth", inline: "nearest", block: "nearest" }); + }, [activeTabIndex]); + + // Non-passive touch listeners for reliable horizontal swipe detection. + // React's onTouchMove is passive, so e.preventDefault() is a no-op there. + // With native { passive: false } we can block the browser's vertical scroll + // when a horizontal swipe is detected, making tab swiping reliable. + useEffect(() => { + const fieldset = fieldsetRef.current; + if (!show || !fieldset) return; + + const AXIS_LOCK_THRESHOLD = 6; + + function resetSwipe() { + swipeStartRef.current = null; + swipeAxisRef.current = null; + setIsHorizontalSwiping(false); + setSwipeDeltaX(0); + } + + function onTouchStart(e: TouchEvent) { + if (e.touches.length !== 1) { + resetSwipe(); + return; + } + const touch = e.touches[0]; + swipeStartRef.current = { x: touch.clientX, y: touch.clientY }; + swipeAxisRef.current = null; + setIsHorizontalSwiping(false); + setSwipeDeltaX(0); + } + + function onTouchMove(e: TouchEvent) { + if (!swipeStartRef.current || e.touches.length !== 1) return; + + const touch = e.touches[0]; + const dx = touch.clientX - swipeStartRef.current.x; + const dy = touch.clientY - swipeStartRef.current.y; + const ax = Math.abs(dx); + const ay = Math.abs(dy); + + if (!swipeAxisRef.current) { + if (ax < AXIS_LOCK_THRESHOLD && ay < AXIS_LOCK_THRESHOLD) return; + swipeAxisRef.current = ax >= ay ? "x" : "y"; + } + + if (swipeAxisRef.current === "y") return; + + // Horizontal swipe — block native vertical scroll + e.preventDefault(); + setIsHorizontalSwiping(true); + + let nextDelta = dx; + const idx = activeTabIndexRef.current; + if ((idx === 0 && nextDelta > 0) || (idx === MOBILE_TAB_ORDER.length - 1 && nextDelta < 0)) { + nextDelta *= 0.35; + } + setSwipeDeltaX(nextDelta); + } + + function onTouchEnd(e: TouchEvent) { + if (!swipeStartRef.current || e.changedTouches.length !== 1) { + resetSwipe(); + return; + } + + if (swipeAxisRef.current === "x") { + const touch = e.changedTouches[0]; + const dx = touch.clientX - swipeStartRef.current.x; + const minSwipe = Math.max(36, (tabViewportRef.current?.clientWidth ?? 360) * 0.1); + if (Math.abs(dx) >= minSwipe) { + const direction = dx < 0 ? 1 : -1; + const idx = activeTabIndexRef.current; + const next = Math.min(Math.max(idx + direction, 0), MOBILE_TAB_ORDER.length - 1); + if (next !== idx) { + setActiveTab(MOBILE_TAB_ORDER[next]); + } + } + } + resetSwipe(); + } + + fieldset.addEventListener("touchstart", onTouchStart, { passive: true }); + fieldset.addEventListener("touchmove", onTouchMove, { passive: false }); + fieldset.addEventListener("touchend", onTouchEnd, { passive: true }); + fieldset.addEventListener("touchcancel", resetSwipe, { passive: true }); + return () => { + fieldset.removeEventListener("touchstart", onTouchStart); + fieldset.removeEventListener("touchmove", onTouchMove); + fieldset.removeEventListener("touchend", onTouchEnd); + fieldset.removeEventListener("touchcancel", resetSwipe); }; }, [show]); if (!show) return null; const currentMed = editingId ? meds.find((m) => m.id === editingId) : null; + const mobileTitle = (() => { + if (!editingId) return t("form.newEntry"); + if (readOnlyMode) return t("form.viewEntry"); + const medicationName = currentMed?.name?.trim() || form.name.trim(); + if (!medicationName) return t("form.editEntry"); + return t("form.editEntryWithName", { name: medicationName }); + })(); return (
← {t("common.back")} -

- {(() => { - const editLabel = readOnlyMode ? t("form.viewEntry") : t("form.editEntry"); - return editingId ? editLabel : t("form.newEntry"); - })()} -

+

{mobileTitle}

-
+
- +
-
-
-
-

{t("form.sections.general")}

- - - - - -
- - {editingId && ( -
-

{t("form.medicationImage")}

- {currentMed?.imageUrl ? ( -
- {currentMed.name} - + + ))} + onTakenByInputChange(e.target.value)} + onKeyDown={onTakenByKeyDown} + onBlur={() => { + if (takenByInput.trim()) onAddTakenByPerson(takenByInput); + }} + placeholder={ + form.takenBy.length === 0 + ? t("form.placeholders.takenBy") + : t("form.placeholders.addPerson") + } + maxLength={FIELD_LIMITS.takenBy.max} + list="takenby-suggestions-modal" + /> + + {existingPeople + .filter((p) => !form.takenBy.includes(p)) + .map((person) => ( + +
+ {fieldErrors.takenBy && {fieldErrors.takenBy}} + +
+ + {editingId && ( +
+

{t("form.medicationImage")}

+ {currentMed?.imageUrl ? ( +
+ {currentMed.name} + +
+ ) : ( + e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])} + /> + )}
- ) : ( - e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])} - /> )}
- )} -
-
-
-

{t("form.sections.stock")}

- {form.packageType === "blister" ? ( - <> - - - - - - ) : ( - <> - - - - )} - {form.packageType === "bottle" && ( -
-
-

- {t("form.total")}: {deriveTotalFromForm(form)}{" "} - {deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")} -

-
-
- )} - - -