fix: prevent background scroll when any modal is open (#284)
Replace CSS-only modal-open class toggle with a shared useScrollLock hook that uses position:fixed + scroll position save/restore. This reliably prevents background scrolling on all browsers including iOS Safari. The hook supports nesting (lock counter) so stacked modals (e.g. MedDetail → RefillModal) work correctly. Also adds missing modal states to the scroll lock: showRefillModal, showEditStockModal, showImageLightbox, scheduleLightboxImage. Replaces the inline 40-line scroll lock in MobileEditModal with the shared hook.
This commit is contained in:
+15
-15
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user