fix(mui): lock <html> scroll for dialogs + freeze Modal title/labels during close
Scroll: MUI only locks <body>, but base.css 'html{overflow-x:hidden}' makes <html> the vertical scroller (overflow-x:hidden forces overflow-y:auto), so the page scrolled behind modals. Added a ref-counted useDialogScrollLock that locks <html> (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) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import DialogContent from "@mui/material/DialogContent";
|
|||||||
import DialogContentText from "@mui/material/DialogContentText";
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
import MuiButton from "@mui/material/Button";
|
import MuiButton from "@mui/material/Button";
|
||||||
|
import useDialogScrollLock from "./useDialogScrollLock";
|
||||||
|
|
||||||
export interface ConfirmDialogProps {
|
export interface ConfirmDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -30,6 +31,7 @@ export default function ConfirmDialog({
|
|||||||
confirmVariant = "primary",
|
confirmVariant = "primary",
|
||||||
loading = false,
|
loading = false,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
|
useDialogScrollLock(isOpen);
|
||||||
// Freeze the shown title/message while closing, so the fade-out transition
|
// 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
|
// 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
|
// selected row to null) at the same instant it closes the dialog. This is
|
||||||
@@ -45,6 +47,7 @@ export default function ConfirmDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={loading ? undefined : onClose}
|
onClose={loading ? undefined : onClose}
|
||||||
|
disableScrollLock
|
||||||
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||||
>
|
>
|
||||||
<DialogTitle>{shown.title}</DialogTitle>
|
<DialogTitle>{shown.title}</DialogTitle>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import MuiButton from "@mui/material/Button";
|
import MuiButton from "@mui/material/Button";
|
||||||
|
import useDialogScrollLock from "./useDialogScrollLock";
|
||||||
|
|
||||||
export interface ModalProps {
|
export interface ModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -34,19 +35,34 @@ export default function Modal({
|
|||||||
cancelText = "Zrušit",
|
cancelText = "Zrušit",
|
||||||
maxWidth = "sm",
|
maxWidth = "sm",
|
||||||
}: ModalProps) {
|
}: 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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={loading ? undefined : onClose}
|
onClose={loading ? undefined : onClose}
|
||||||
fullWidth
|
fullWidth
|
||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
|
disableScrollLock
|
||||||
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||||
>
|
>
|
||||||
<DialogTitle sx={{ pb: subtitle ? 0.5 : 1.5 }}>
|
<DialogTitle sx={{ pb: shown.subtitle ? 0.5 : 1.5 }}>
|
||||||
{title}
|
{shown.title}
|
||||||
{subtitle && (
|
{shown.subtitle && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{subtitle}
|
{shown.subtitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@@ -60,7 +76,7 @@ export default function Modal({
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={loading || submitDisabled}
|
disabled={loading || submitDisabled}
|
||||||
>
|
>
|
||||||
{loading ? "Ukládám…" : submitText}
|
{loading ? "Ukládám…" : shown.submitText}
|
||||||
</MuiButton>
|
</MuiButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
32
src/admin/ui/useDialogScrollLock.ts
Normal file
32
src/admin/ui/useDialogScrollLock.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Ref-counted lock of the <html> scroll. MUI's Dialog only locks <body>, but
|
||||||
|
// base.css sets `html { overflow-x: hidden }`, which (per the CSS spec) forces
|
||||||
|
// html's overflow-y to `auto` — making <html> the vertical scroll container.
|
||||||
|
// So body-locking alone leaves the page scrollable behind the modal. We lock
|
||||||
|
// <html> 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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user