From 305c3fd97bed773fd32a4f1e03e9630a2ca90e11 Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 6 Jun 2026 21:59:38 +0200 Subject: [PATCH] fix(mui): lock scroll for dialogs + freeze Modal title/labels during close Scroll: MUI only locks , but base.css 'html{overflow-x:hidden}' makes the vertical scroller (overflow-x:hidden forces overflow-y:auto), so the page scrolled behind modals. Added a ref-counted useDialogScrollLock that locks (with scrollbar-width padding) + disableScrollLock on the kit dialogs. Labels: Modal now freezes title/subtitle/submitText while closing (derive-state-from-props), so editing then saving no longer briefly flashes the 'create' title/button during the fade-out. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/admin/ui/ConfirmDialog.tsx | 3 +++ src/admin/ui/Modal.tsx | 28 +++++++++++++++++++------ src/admin/ui/useDialogScrollLock.ts | 32 +++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 src/admin/ui/useDialogScrollLock.ts diff --git a/src/admin/ui/ConfirmDialog.tsx b/src/admin/ui/ConfirmDialog.tsx index 3a6cc72..0bd207a 100644 --- a/src/admin/ui/ConfirmDialog.tsx +++ b/src/admin/ui/ConfirmDialog.tsx @@ -5,6 +5,7 @@ import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogActions from "@mui/material/DialogActions"; import MuiButton from "@mui/material/Button"; +import useDialogScrollLock from "./useDialogScrollLock"; export interface ConfirmDialogProps { isOpen: boolean; @@ -30,6 +31,7 @@ export default function ConfirmDialog({ confirmVariant = "primary", loading = false, }: ConfirmDialogProps) { + useDialogScrollLock(isOpen); // Freeze the shown title/message while closing, so the fade-out transition // never flashes empty when the caller clears its source data (e.g. sets the // selected row to null) at the same instant it closes the dialog. This is @@ -45,6 +47,7 @@ export default function ConfirmDialog({ {shown.title} diff --git a/src/admin/ui/Modal.tsx b/src/admin/ui/Modal.tsx index d720a0b..7b55fb0 100644 --- a/src/admin/ui/Modal.tsx +++ b/src/admin/ui/Modal.tsx @@ -1,10 +1,11 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import Dialog from "@mui/material/Dialog"; import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import DialogActions from "@mui/material/DialogActions"; import Typography from "@mui/material/Typography"; import MuiButton from "@mui/material/Button"; +import useDialogScrollLock from "./useDialogScrollLock"; export interface ModalProps { isOpen: boolean; @@ -34,19 +35,34 @@ export default function Modal({ cancelText = "Zrušit", maxWidth = "sm", }: ModalProps) { + useDialogScrollLock(isOpen); + // Freeze the shown title/subtitle/submitText while closing, so the fade-out + // never flashes the "create" variant when the caller clears `editingUser` on + // close (React derive-state-from-props: update only while open). + const [shown, setShown] = useState({ title, subtitle, submitText }); + if ( + isOpen && + (shown.title !== title || + shown.subtitle !== subtitle || + shown.submitText !== submitText) + ) { + setShown({ title, subtitle, submitText }); + } + return ( - - {title} - {subtitle && ( + + {shown.title} + {shown.subtitle && ( - {subtitle} + {shown.subtitle} )} @@ -60,7 +76,7 @@ export default function Modal({ variant="contained" disabled={loading || submitDisabled} > - {loading ? "Ukládám…" : submitText} + {loading ? "Ukládám…" : shown.submitText} diff --git a/src/admin/ui/useDialogScrollLock.ts b/src/admin/ui/useDialogScrollLock.ts new file mode 100644 index 0000000..bd69ddf --- /dev/null +++ b/src/admin/ui/useDialogScrollLock.ts @@ -0,0 +1,32 @@ +import { useEffect } from "react"; + +// Ref-counted lock of the scroll. MUI's Dialog only locks , but +// base.css sets `html { overflow-x: hidden }`, which (per the CSS spec) forces +// html's overflow-y to `auto` — making the vertical scroll container. +// So body-locking alone leaves the page scrollable behind the modal. We lock +// directly and pad for the removed scrollbar to avoid a layout shift. +let lockCount = 0; +let prevOverflow = ""; +let prevPaddingRight = ""; + +export default function useDialogScrollLock(open: boolean) { + useEffect(() => { + if (!open) return; + const el = document.documentElement; + if (lockCount === 0) { + const scrollbarWidth = window.innerWidth - el.clientWidth; + prevOverflow = el.style.overflow; + prevPaddingRight = el.style.paddingRight; + el.style.overflow = "hidden"; + if (scrollbarWidth > 0) el.style.paddingRight = `${scrollbarWidth}px`; + } + lockCount += 1; + return () => { + lockCount -= 1; + if (lockCount === 0) { + el.style.overflow = prevOverflow; + el.style.paddingRight = prevPaddingRight; + } + }; + }, [open]); +}