DocDoeAI / src /components /shared /PageTransitionLoader.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
4.4 kB
"use client";
import {
Suspense,
useCallback,
useEffect,
useRef,
useState,
type MutableRefObject,
} from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
const REVEAL_DELAY_MS = 120;
const COMPLETION_MS = 140;
const FAILSAFE_MS = 10_000;
type LoaderPhase = "idle" | "pending" | "completing";
function clearTimer(timer: MutableRefObject<number | null>) {
if (timer.current === null) return;
window.clearTimeout(timer.current);
timer.current = null;
}
export function isSameDocumentNavigation(anchor: HTMLAnchorElement): boolean {
if (anchor.target && anchor.target !== "_self") return false;
if (anchor.hasAttribute("download")) return false;
const next = new URL(anchor.href, window.location.href);
const current = new URL(window.location.href);
if (next.origin !== current.origin) return false;
return `${next.pathname}${next.search}` !== `${current.pathname}${current.search}`;
}
function PageTransitionLoaderInner() {
const pathname = usePathname();
const searchParams = useSearchParams();
const routeKey = `${pathname}?${searchParams.toString()}`;
const reduceMotion = useReducedMotion();
const [phase, setPhase] = useState<LoaderPhase>("idle");
const isFirstPaint = useRef(true);
const navigationArmed = useRef(false);
const revealTimer = useRef<number | null>(null);
const completionTimer = useRef<number | null>(null);
const failsafeTimer = useRef<number | null>(null);
const beginNavigation = useCallback(() => {
clearTimer(revealTimer);
clearTimer(completionTimer);
clearTimer(failsafeTimer);
navigationArmed.current = true;
revealTimer.current = window.setTimeout(() => {
setPhase("pending");
revealTimer.current = null;
}, REVEAL_DELAY_MS);
failsafeTimer.current = window.setTimeout(() => {
navigationArmed.current = false;
setPhase("idle");
failsafeTimer.current = null;
}, FAILSAFE_MS);
}, []);
useEffect(() => {
function handleDocumentClick(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
const target = event.target;
if (!(target instanceof Element)) return;
const anchor = target.closest<HTMLAnchorElement>("a[href]");
if (!anchor || !isSameDocumentNavigation(anchor)) return;
beginNavigation();
}
window.addEventListener("popstate", beginNavigation);
document.addEventListener("click", handleDocumentClick, true);
return () => {
window.removeEventListener("popstate", beginNavigation);
document.removeEventListener("click", handleDocumentClick, true);
clearTimer(revealTimer);
clearTimer(completionTimer);
clearTimer(failsafeTimer);
};
}, [beginNavigation]);
useEffect(() => {
if (isFirstPaint.current) {
isFirstPaint.current = false;
return;
}
if (!navigationArmed.current) return;
navigationArmed.current = false;
clearTimer(revealTimer);
clearTimer(failsafeTimer);
setPhase("completing");
completionTimer.current = window.setTimeout(() => {
setPhase("idle");
completionTimer.current = null;
}, COMPLETION_MS);
return () => clearTimer(completionTimer);
}, [routeKey]);
return (
<AnimatePresence>
{phase !== "idle" ? (
<motion.div
className="pointer-events-none fixed inset-x-0 top-0 z-[120] h-[2px] origin-left bg-[#171716]"
initial={reduceMotion ? false : { scaleX: 0.08, opacity: 0 }}
animate={
phase === "completing"
? { scaleX: 1, opacity: 1 }
: reduceMotion
? { scaleX: 0.72, opacity: 0.72 }
: { scaleX: 0.72, opacity: 1 }
}
exit={{ opacity: 0 }}
transition={{
duration: reduceMotion ? 0 : phase === "completing" ? 0.12 : 0.8,
ease: [0.16, 1, 0.3, 1],
}}
data-testid="page-transition-loader"
aria-hidden
/>
) : null}
</AnimatePresence>
);
}
export function PageTransitionLoader() {
return (
<Suspense fallback={null}>
<PageTransitionLoaderInner />
</Suspense>
);
}