cellex-web / src /components /ios-stack.tsx
eeshaAI
feat: Migrate frontend from static HTML to Next.js 16 App Router
a09d2a4
Raw
History Blame Contribute Delete
6.8 kB
'use client';
/**
* IOSStack — iOS-style navigation with "Move with me" drag + frozen previous page.
*
* FIX: hasPrevious uses a ref (not state) to avoid race conditions between
* the drag-back callback's setHasPrevious(false) and the render-time
* setHasPrevious(true). Refs update synchronously — no batching issues.
*/
import { motion, AnimatePresence, useMotionValue, useTransform, animate } from 'framer-motion';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useRef, useState, type ReactNode, type TouchEvent as ReactTouchEvent } from 'react';
const SLIDE_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
const PUSH_DURATION = 0.45;
const BACK_DURATION = 0.4;
const EDGE_ZONE_PX = 28;
const COMMIT_THRESHOLD_PX = 120;
export function IOSStack({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 375;
// --- Frozen previous page ---
const previousChildren = useRef<ReactNode | null>(null);
const lastChildren = useRef<ReactNode>(children);
const lastPath = useRef<string>(pathname);
// FIX: Use a ref for hasPrevious to avoid race conditions.
// State batching can cause setHasPrevious(false) from drag-back to
// override setHasPrevious(true) from the render-time check.
const hasPreviousRef = useRef(false);
const [hasPreviousState, setHasPreviousState] = useState(false);
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
// Helper to update both ref and state
const setHasPrevious = (val: boolean) => {
hasPreviousRef.current = val;
setHasPreviousState(val);
};
// --- Render-time capture of previous children ---
if (lastPath.current !== pathname) {
previousChildren.current = lastChildren.current;
setHasPrevious(true);
setDirection(lastPath.current.length > pathname.length ? 'back' : 'forward');
lastPath.current = pathname;
}
lastChildren.current = children;
// --- Motion values ---
const x = useMotionValue(0);
const prevPageX = useTransform(x, [0, windowWidth], [0, windowWidth * 0.2]);
const shadowOpacity = useTransform(x, [0, windowWidth], [0.35, 0]);
const prevPageScale = useTransform(x, [0, windowWidth], [0.96, 1]);
// --- Manual touch handlers ---
const touchState = useRef<{
startX: number;
startY: number;
active: boolean;
horizontal: boolean | null;
}>({ startX: 0, startY: 0, active: false, horizontal: null });
const [isDragging, setIsDragging] = useState(false);
function onTouchStart(e: ReactTouchEvent<HTMLDivElement>) {
// FIX: Use the ref (synchronous) instead of state (may be stale due to batching)
if (!hasPreviousRef.current) return;
const t = e.touches[0];
if (!t || t.clientX > EDGE_ZONE_PX) {
touchState.current = { startX: 0, startY: 0, active: false, horizontal: null };
return;
}
touchState.current = {
startX: t.clientX,
startY: t.clientY,
active: true,
horizontal: null,
};
}
function onTouchMove(e: ReactTouchEvent<HTMLDivElement>) {
const state = touchState.current;
if (!state.active) return;
const t = e.touches[0];
if (!t) return;
const dx = t.clientX - state.startX;
const dy = t.clientY - state.startY;
if (state.horizontal === null) {
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx < 8 && absDy < 8) return;
if (absDy > absDx) {
state.active = false;
return;
}
state.horizontal = true;
setIsDragging(true);
}
if (!state.horizontal) return;
if (e.cancelable) e.preventDefault();
const clamped = Math.max(0, Math.min(dx, windowWidth));
x.set(clamped);
}
function onTouchEnd() {
const state = touchState.current;
touchState.current = { startX: 0, startY: 0, active: false, horizontal: null };
if (!state.active || !state.horizontal) {
setIsDragging(false);
return;
}
const current = x.get();
if (current >= COMMIT_THRESHOLD_PX) {
// Commit: animate off-screen, then navigate back
animate(x, windowWidth, {
duration: 0.3,
ease: SLIDE_EASE,
onComplete: () => {
// FIX: Don't clear hasPrevious here — let the render-time check
// handle it when the pathname changes after router.back().
// This prevents the race condition where setHasPrevious(false)
// overrides the render-time setHasPrevious(true).
router.back();
requestAnimationFrame(() => {
x.set(0);
setIsDragging(false);
});
},
});
} else {
animate(x, 0, {
duration: 0.3,
ease: SLIDE_EASE,
onComplete: () => setIsDragging(false),
});
}
}
// --- Animation variants ---
const variants = {
enter: (dir: string) => ({
x: dir === 'forward' ? '100%' : '0%',
zIndex: 10,
}),
center: {
x: 0,
zIndex: 10,
transition: { duration: PUSH_DURATION, ease: SLIDE_EASE },
},
exit: (dir: string) => ({
x: dir === 'forward' ? '-20%' : '100%',
zIndex: dir === 'forward' ? 1 : 10,
transition: { duration: BACK_DURATION, ease: SLIDE_EASE },
}),
};
return (
<div style={{ position: 'fixed', inset: 0, overflow: 'hidden', background: '#000' }}>
{/* Layer 0: Frozen Previous Page */}
{hasPreviousState && previousChildren.current && (
<motion.div
className="absolute inset-0 bg-white"
style={{
x: prevPageX,
scale: prevPageScale,
zIndex: 0,
pointerEvents: 'none',
}}
aria-hidden="true"
>
{previousChildren.current}
<motion.div
className="absolute inset-0 bg-black pointer-events-none"
style={{ opacity: shadowOpacity }}
/>
</motion.div>
)}
{/* Layer 1: Active Page */}
<AnimatePresence mode="popLayout" custom={direction} initial={false}>
<motion.div
key={pathname}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
style={{
x,
zIndex: 10,
position: 'absolute',
inset: 0,
background: '#fff',
boxShadow: isDragging ? '-8px 0 25px rgba(0,0,0,0.2)' : 'none',
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
>
{children}
</motion.div>
</AnimatePresence>
</div>
);
}