diff --git a/packages/web/src/components/layout/MobileScreenStack.tsx b/packages/web/src/components/layout/MobileScreenStack.tsx new file mode 100644 index 00000000..0f0de678 --- /dev/null +++ b/packages/web/src/components/layout/MobileScreenStack.tsx @@ -0,0 +1,88 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { useUIStore } from '../../stores/uiStore'; + +interface MobileScreenStackProps { + rootScreen: React.ReactNode; + screenMap: Record) => React.ReactNode>; +} + +export function MobileScreenStack({ rootScreen, screenMap }: MobileScreenStackProps) { + const mobileStack = useUIStore((s) => s.mobileStack); + const [transitioning, setTransitioning] = useState<'push' | 'pop' | null>(null); + const [renderStack, setRenderStack] = useState(mobileStack); + const prevStackRef = useRef(mobileStack); + const animatingRef = useRef(false); + + useEffect(() => { + const prevStack = prevStackRef.current; + const prevLen = prevStack.length; + const newLen = mobileStack.length; + prevStackRef.current = mobileStack; + + if (newLen > prevLen) { + // Push — render new screen off-screen, then animate in on next frame + setRenderStack(mobileStack); + setTransitioning('push'); + animatingRef.current = true; + // Use rAF to ensure the off-screen position is painted before animating + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setTransitioning(null); + animatingRef.current = false; + }); + }); + } else if (newLen < prevLen) { + // Pop — keep old screen visible during exit animation, then remove + setTransitioning('pop'); + animatingRef.current = true; + const timer = setTimeout(() => { + setRenderStack(mobileStack); + setTransitioning(null); + animatingRef.current = false; + }, 200); + return () => clearTimeout(timer); + } else { + setRenderStack(mobileStack); + } + }, [mobileStack]); + + return ( +
+ {/* Root screen — always rendered underneath, invisible when covered by stack */} +
0 ? 'hidden' : 'visible' }} + > + {rootScreen} +
+ + {/* Stacked screens */} + {renderStack.map((entry, index) => { + const isTop = index === renderStack.length - 1; + const renderer = screenMap[entry.screen]; + if (!renderer) return null; + + let style: React.CSSProperties = {}; + let className = 'absolute inset-0 bg-surface-base z-10 flex flex-col'; + + if (isTop && transitioning === 'push') { + // Initial position: off-screen right + style.transform = 'translateX(100%)'; + } else if (isTop && transitioning === 'pop') { + // Animate out to the right + className += ' transition-transform duration-200 ease-out'; + style.transform = 'translateX(100%)'; + } else if (isTop && !transitioning && !animatingRef.current) { + // Settled position + className += ' transition-transform duration-200 ease-out'; + } + + return ( +
+ {renderer(entry.params)} +
+ ); + })} +
+ ); +}