diff --git a/components/overlays/overlay-container.tsx b/components/overlays/overlay-container.tsx index 4f8febde0..c0e4bd697 100644 --- a/components/overlays/overlay-container.tsx +++ b/components/overlays/overlay-container.tsx @@ -9,7 +9,7 @@ import { } from "motion/react"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { Drawer as DrawerPrimitive } from "vaul"; -import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog"; +import { Dialog, DialogPortal } from "@/components/ui/dialog"; import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { useOverlay } from "./overlay-provider"; @@ -30,8 +30,6 @@ const drawerSpring = { mass: 0.8, } as const; -const reducedMotion = { duration: 0.01 }; - /** * Variants for the dialog container (fade in/out) */ @@ -84,32 +82,20 @@ const drawerContainerVariants: Variants = { }; /** - * Slide variants that use custom prop for direction - * custom > 0: pushing (new from right, old exits left) - * custom < 0: popping (new from left, old exits right) + * Get x position for overlay item based on its position relative to current */ -const createSlideVariants = (shouldReduceMotion: boolean | null): Variants => ({ - enter: (direction: number) => ({ - x: direction > 0 ? "100%" : "-35%", - scale: direction > 0 ? 1 : 0.94, - opacity: direction > 0 ? 1 : 0, - }), - center: { - x: "0%", - scale: 1, - opacity: 1, - transition: shouldReduceMotion ? reducedMotion : iosSpring, - }, - exit: (direction: number) => ({ - x: direction > 0 ? "-35%" : "100%", - scale: direction > 0 ? 0.94 : 1, - opacity: direction > 0 ? 0 : 1, - transition: shouldReduceMotion ? reducedMotion : iosSpring, - }), -}); +function getOverlayXPosition( + isCurrent: boolean, + isPrevious: boolean +): "0%" | "-35%" | "100%" { + if (isCurrent) return "0%"; + if (isPrevious) return "-35%"; + return "100%"; +} /** - * Hook to track direction of stack changes + * Hook to track direction of stack changes (push vs pop) + * Returns 1 for push, -1 for pop */ function useStackDirection(stackLength: number) { const prevLength = useRef(stackLength); @@ -117,9 +103,9 @@ function useStackDirection(stackLength: number) { // Compute synchronously during render for immediate value if (stackLength > prevLength.current) { - direction.current = 1; + direction.current = 1; // pushing } else if (stackLength < prevLength.current) { - direction.current = -1; + direction.current = -1; // popping } // Update prevLength after render @@ -132,17 +118,38 @@ function useStackDirection(stackLength: number) { /** * Desktop dialog container with internal sliding content + * Renders all stack items persistently in the same React tree location, + * using CSS transforms to animate visibility while preserving component state */ function DesktopOverlayContainer() { const { stack, closeAll, pop } = useOverlay(); const shouldReduceMotion = useReducedMotion(); const [minHeight, setMinHeight] = useState(0); - const direction = useStackDirection(stack.length); const contentRef = useRef(null); const wasOpenRef = useRef(false); + const frozenStackRef = useRef(stack); + const direction = useStackDirection(stack.length); const isOpen = stack.length > 0; + // Freeze the stack when open so content doesn't shift during exit animation + // AnimatePresence keeps children mounted during exit, so frozenStack is used then + if (isOpen) { + frozenStackRef.current = stack; + } + + // Use frozen stack for rendering (preserves content during exit) + const renderStack = frozenStackRef.current; + const currentIndex = renderStack.length - 1; + + // DEBUG + console.log("[DesktopOverlay]", { + isOpen, + stackLength: stack.length, + frozenStackLength: frozenStackRef.current.length, + renderStackLength: renderStack.length, + }); + // Measure content height when it changes, reset on fresh open useLayoutEffect(() => { const isFreshOpen = isOpen && !wasOpenRef.current; @@ -161,8 +168,10 @@ function DesktopOverlayContainer() { } }, [stack, isOpen]); + // Use live stack for options checks (only when open) const currentItem = stack[stack.length - 1]; - const slideVariants = createSlideVariants(shouldReduceMotion); + const springTransition = shouldReduceMotion ? { duration: 0.01 } : iosSpring; + const isPushing = direction === 1; const handleBackdropClick = useCallback(() => { if (currentItem?.options.closeOnBackdropClick !== false) { @@ -186,22 +195,32 @@ function DesktopOverlayContainer() { } }, [isOpen, handleEscapeKey]); + const handleExitComplete = useCallback(() => { + console.log("[DesktopOverlay] handleExitComplete called"); + frozenStackRef.current = []; + }, []); + + console.log("[DesktopOverlay] Rendering, isOpen:", isOpen); + + // Don't render Dialog at all when closed - this ensures clean unmount + if (!isOpen && frozenStackRef.current.length === 0) { + return null; + } + return ( - - - {isOpen && ( + + {isOpen && ( + - {/* Backdrop */} - - - + {/* Backdrop - standalone clickable div */} + {/* Dialog container */} 0 ? minHeight : "auto" }} transition={iosSpring} > - {/* Content area - ref on wrapper div to avoid React 19 issues */} -
- - {currentItem && ( + {/* Content area - all items rendered persistently to preserve state */} +
+ {renderStack.map((item, index) => { + const isCurrent = index === currentIndex; + const isPrevious = index < currentIndex; + + // For push onto existing stack: new current item slides in from right + // For first overlay (fresh open): no slide, dialog container handles entrance + // For pop: returning item is already at -35%, animates to 0% + const shouldSlideIn = + isCurrent && isPushing && renderStack.length > 1; + const initialValue = shouldSlideIn + ? { x: "100%", scale: 1, opacity: 1 } + : false; + + return ( - + - )} - + ); + })}
- )} -
-
+
+ )} + ); } /** * Mobile drawer container with internal sliding content + * Renders all stack items persistently in the same React tree location, + * using CSS transforms to animate visibility while preserving component state */ function MobileOverlayContainer() { const { stack, closeAll, pop } = useOverlay(); const shouldReduceMotion = useReducedMotion(); const [minHeight, setMinHeight] = useState(0); - const direction = useStackDirection(stack.length); const contentRef = useRef(null); const wasOpenRef = useRef(false); + const frozenStackRef = useRef(stack); + const direction = useStackDirection(stack.length); const isOpen = stack.length > 0; + // Freeze the stack when open so content doesn't shift during exit animation + if (isOpen) { + frozenStackRef.current = stack; + } + + // Use frozen stack for rendering (preserves content during exit) + const renderStack = frozenStackRef.current; + const currentIndex = renderStack.length - 1; + // Measure content height when it changes, reset on fresh open useLayoutEffect(() => { const isFreshOpen = isOpen && !wasOpenRef.current; @@ -284,14 +328,11 @@ function MobileOverlayContainer() { } }, [stack, isOpen]); + // Use live stack for options checks (only when open) const currentItem = stack[stack.length - 1]; - const slideVariants = createSlideVariants(shouldReduceMotion); - - const handleBackdropClick = useCallback(() => { - if (currentItem?.options.closeOnBackdropClick !== false) { - closeAll(); - } - }, [currentItem?.options.closeOnBackdropClick, closeAll]); + const renderCurrentItem = renderStack[currentIndex]; + const springTransition = shouldReduceMotion ? { duration: 0.01 } : iosSpring; + const isPushing = direction === 1; const handleEscapeKey = useCallback( (e: KeyboardEvent) => { @@ -309,86 +350,95 @@ function MobileOverlayContainer() { } }, [isOpen, handleEscapeKey]); + // Clear frozen stack after drawer closes + const handleAnimationEnd = useCallback(() => { + if (!isOpen) { + frozenStackRef.current = []; + } + }, [isOpen]); + return ( - - - {isOpen && ( - - {/* Backdrop */} - - - - - {/* Drawer container */} - - - {/* Accessible title for screen readers */} - - {currentItem?.options.title || "Dialog"} - - - {/* Drag handle */} -
- - {/* Content area with height animation */} - - 0 ? minHeight : "auto" }} - transition={drawerSpring} - > - {/* Content wrapper */} -
- - {currentItem && ( - - - - )} - -
-
-
- - {/* Safe area padding for iOS */} -
- - - - )} - + { + if (!open) { + closeAll(); + } + }} + open={isOpen} + > + + {/* Backdrop - let Vaul handle animations */} + + + {/* Drawer container - let Vaul handle open/close animations */} + + {/* Accessible title for screen readers */} + + {renderCurrentItem?.options.title || "Dialog"} + + + {/* Drag handle */} +
+ + {/* Content area with height animation */} + + 0 ? minHeight : "auto" }} + transition={drawerSpring} + > + {/* Content wrapper - all items rendered persistently to preserve state */} +
+ {renderStack.map((item, index) => { + const isCurrent = index === currentIndex; + const isPrevious = index < currentIndex; + + // For push onto existing stack: new current item slides in from right + // For first overlay (fresh open): no slide, drawer container handles entrance + // For pop: returning item is already at -35%, animates to 0% + const shouldSlideIn = + isCurrent && isPushing && renderStack.length > 1; + const initialValue = shouldSlideIn + ? { x: "100%", scale: 1, opacity: 1 } + : false; + + return ( + + + + ); + })} +
+
+
+ + {/* Safe area padding for iOS */} +
+ + ); } diff --git a/components/overlays/overlay-header.tsx b/components/overlays/overlay-header.tsx index 16cbf575e..3fe5ede43 100644 --- a/components/overlays/overlay-header.tsx +++ b/components/overlays/overlay-header.tsx @@ -42,18 +42,13 @@ export function OverlayHeader({ }; return ( -
-
+
+ {/* Fixed min-height to prevent layout shift when back button appears */} +
{showBackButton && (