diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c51b9b8..2acfd67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { import { AppHeader } from "./components/AppHeader"; import { AuthPage, AuthProvider, useAuth } from "./components/Auth"; import { AppProvider, UnsavedChangesProvider, useAppContext } from "./context"; +import { useScrollLock } from "./hooks/useScrollLock"; import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage } from "./pages"; // Vite injects this at build time from package.json @@ -340,21 +341,20 @@ function AppContent() { }; }, []); - // Prevent background scroll when modal is open - useEffect(() => { - const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog; - if (isModalOpen) { - document.documentElement.classList.add("modal-open"); - document.body.classList.add("modal-open"); - } else { - document.documentElement.classList.remove("modal-open"); - document.body.classList.remove("modal-open"); - } - return () => { - document.documentElement.classList.remove("modal-open"); - document.body.classList.remove("modal-open"); - }; - }, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog]); + // Prevent background scroll when any modal is open + useScrollLock( + !!( + selectedMed || + selectedUser || + showProfile || + showAbout || + showShareDialog || + showRefillModal || + showEditStockModal || + showImageLightbox || + scheduleLightboxImage + ) + ); // Update selectedMed when meds change (e.g., after refill) useEffect(() => { diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index 296f65e..b09ea18 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -7,6 +7,7 @@ import { Bell, Minus, Plus, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useScrollLock } from "../hooks/useScrollLock"; import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types"; import { DOSE_UNITS } from "../types"; import { deriveTotal } from "../utils"; @@ -138,54 +139,7 @@ export function MobileEditModal({ }, [show, onClose]); // Lock background scroll while modal is open. - useEffect(() => { - if (!show) return; - 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 () => { - 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]); + useScrollLock(show); // Keep activeTabIndex ref in sync for native listeners const activeTabIndex = MOBILE_TAB_ORDER.indexOf(activeTab); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index e97c1dd..25bf2f7 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -11,6 +11,7 @@ export { useMedications } from "./useMedications"; export { useModalHistory } from "./useModalHistory"; export type { UseRefillReturn } from "./useRefill"; export { useRefill } from "./useRefill"; +export { useScrollLock } from "./useScrollLock"; export type { Settings, UseSettingsReturn } from "./useSettings"; export { useSettings } from "./useSettings"; export type { UseShareReturn } from "./useShare"; diff --git a/frontend/src/hooks/useScrollLock.ts b/frontend/src/hooks/useScrollLock.ts new file mode 100644 index 0000000..211e4eb --- /dev/null +++ b/frontend/src/hooks/useScrollLock.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from "react"; + +/** + * Lock background scrolling when a modal/overlay is visible. + * + * Uses the `position: fixed` technique to prevent scroll on iOS Safari + * and other browsers where `overflow: hidden` alone is insufficient. + * Saves and restores the scroll position on cleanup so users don't + * lose their place. + * + * Supports nesting: a scroll-lock counter prevents premature unlock + * when multiple modals stack (e.g. MedDetail → RefillModal). + */ + +let lockCount = 0; +let savedScrollY = 0; + +export function useScrollLock(active: boolean): void { + const wasActive = useRef(false); + + useEffect(() => { + if (active && !wasActive.current) { + wasActive.current = true; + const html = document.documentElement; + const body = document.body; + + if (lockCount === 0) { + savedScrollY = window.scrollY; + html.classList.add("modal-open"); + html.style.overflow = "hidden"; + html.style.overscrollBehavior = "none"; + body.classList.add("modal-open"); + body.style.overflow = "hidden"; + body.style.position = "fixed"; + body.style.top = `-${savedScrollY}px`; + body.style.left = "0"; + body.style.right = "0"; + body.style.width = "100%"; + body.style.overscrollBehavior = "none"; + } + lockCount++; + } + + if (!active && wasActive.current) { + wasActive.current = false; + lockCount--; + if (lockCount <= 0) { + lockCount = 0; + unlock(); + } + } + + return () => { + if (wasActive.current) { + wasActive.current = false; + lockCount--; + if (lockCount <= 0) { + lockCount = 0; + unlock(); + } + } + }; + }, [active]); +} + +function unlock(): void { + const html = document.documentElement; + const body = document.body; + html.classList.remove("modal-open"); + html.style.overflow = ""; + html.style.overscrollBehavior = ""; + body.classList.remove("modal-open"); + body.style.overflow = ""; + body.style.position = ""; + body.style.top = ""; + body.style.left = ""; + body.style.right = ""; + body.style.width = ""; + body.style.overscrollBehavior = ""; + window.scrollTo(0, savedScrollY); +}