From cbec9a63da62d62a802ee6b193c5422d5678287a Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 7 Aug 2026 22:34:17 +0800 Subject: [PATCH] fix modal scroll position jumps --- vite-frontend/src/components/ui/dialog.tsx | 13 ++-- .../src/shadcn-bridge/heroui/modal.tsx | 75 +++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/vite-frontend/src/components/ui/dialog.tsx b/vite-frontend/src/components/ui/dialog.tsx index 8923c0e..4ac65c9 100644 --- a/vite-frontend/src/components/ui/dialog.tsx +++ b/vite-frontend/src/components/ui/dialog.tsx @@ -28,12 +28,13 @@ function DialogClose({ return ; } -function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { return ( ); -} +}); + +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; function DialogContent({ className, diff --git a/vite-frontend/src/shadcn-bridge/heroui/modal.tsx b/vite-frontend/src/shadcn-bridge/heroui/modal.tsx index 72c68af..231cefb 100644 --- a/vite-frontend/src/shadcn-bridge/heroui/modal.tsx +++ b/vite-frontend/src/shadcn-bridge/heroui/modal.tsx @@ -49,6 +49,41 @@ function useModalContext() { return React.useContext(ModalContext); } +interface ScrollPosition { + element: HTMLElement | null; + left: number; + top: number; +} + +function captureScrollPositions(): ScrollPosition[] { + const positions: ScrollPosition[] = [ + { element: null, left: window.scrollX, top: window.scrollY }, + ]; + + for (const element of Array.from( + document.querySelectorAll("main, [data-scroll-container]"), + )) { + positions.push({ + element, + left: element.scrollLeft, + top: element.scrollTop, + }); + } + + return positions; +} + +function restoreScrollPositions(positions: ScrollPosition[]) { + for (const position of positions) { + if (position.element) { + position.element.scrollLeft = position.left; + position.element.scrollTop = position.top; + } else { + window.scrollTo(position.left, position.top); + } + } +} + type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "full"; function mapSize(size: ModalSize | undefined) { @@ -97,6 +132,46 @@ export function Modal({ scrollBehavior, size, }: ModalProps) { + const previousScrollPositionsRef = React.useRef( + null, + ); + + // Radix focus management and scroll locking can move an ancestor scroll + // container when a modal is opened from a card/grid item. Capture the + // current positions before the open render and restore them after focus + // settles so opening a modal never changes the page position. + React.useLayoutEffect(() => { + return () => { + if (!isOpen) { + previousScrollPositionsRef.current = captureScrollPositions(); + } + }; + }, [isOpen]); + + React.useLayoutEffect(() => { + const positions = previousScrollPositionsRef.current; + + if (!isOpen || !positions) { + return; + } + + restoreScrollPositions(positions); + let nestedFrame = 0; + const frame = window.requestAnimationFrame(() => { + restoreScrollPositions(positions); + nestedFrame = window.requestAnimationFrame(() => + restoreScrollPositions(positions), + ); + }); + + previousScrollPositionsRef.current = null; + + return () => { + window.cancelAnimationFrame(frame); + window.cancelAnimationFrame(nestedFrame); + }; + }, [isOpen]); + const handleOpenChange = (open: boolean) => { onOpenChange?.(open); if (!open) {