"use client"; import { AnimatePresence, animate, type MotionValue, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type ReactNode, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState, } from "react"; import { EASE_IN_OUT, EASE_OUT, SPRING_PANEL, SPRING_SWAP, } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type PullToRefreshStatus = | "idle" | "pulling" | "ready" | "refreshing"; export interface PullToRefreshProps { /** Runs after the user pulls beyond the threshold and releases. */ onRefresh: () => void | Promise; children: ReactNode; /** Keeps the indicator active while an externally managed refresh runs. */ refreshing?: boolean; disabled?: boolean; /** Resisted pull distance in pixels required to refresh. */ threshold?: number; /** Maximum resisted pull distance in pixels. */ maxPull?: number; /** Content offset in pixels while refreshing. */ holdDistance?: number; pullingLabel?: ReactNode; releaseLabel?: ReactNode; refreshingLabel?: ReactNode; ariaLabel?: string; className?: string; contentClassName?: string; indicatorClassName?: string; } type Gesture = { active: boolean; startX: number; startY: number; pointerId: number | null; }; const EMPTY_GESTURE: Gesture = { active: false, startX: 0, startY: 0, pointerId: null, }; // This character needs a compact repeating rhythm rather than a settling // spring: a small orbit and blink that read as activity without feeling busy. const CHARACTER_LOOP = { duration: 0.9, ease: EASE_IN_OUT, repeat: Number.POSITIVE_INFINITY, } as const; const CALM_PULSE = { duration: 1.2, ease: EASE_IN_OUT, repeat: Number.POSITIVE_INFINITY, } as const; const LABEL_SWAP = { duration: 0.16, ease: EASE_OUT } as const; function resistedDistance(distance: number, maxPull: number) { return maxPull * (1 - Math.exp(-Math.max(0, distance) / maxPull)); } function RefreshBuddy({ progress, status, reduce, }: { progress: MotionValue; status: PullToRefreshStatus; reduce: boolean; }) { const lift = useTransform(progress, [0, 1], [-7, 0]); const tilt = useTransform(progress, [0, 1], [-10, 0]); const stretch = useTransform(progress, [0, 0.55, 1], [0.68, 1.1, 0.92]); const ready = status === "ready"; const refreshing = status === "refreshing"; return ( ); } export function PullToRefresh({ onRefresh, children, refreshing = false, disabled = false, threshold = 76, maxPull = 132, holdDistance = 68, pullingLabel = "Pull to refresh", releaseLabel = "Release to refresh", refreshingLabel = "Refreshing", ariaLabel = "Refreshable content", className, contentClassName, indicatorClassName, }: PullToRefreshProps) { const rootRef = useRef(null); const gestureRef = useRef({ ...EMPTY_GESTURE }); const animationRef = useRef<{ stop: () => void } | null>(null); const statusRef = useRef("idle"); const disabledRef = useRef(disabled); const externalRefreshingRef = useRef(refreshing); const refreshingRef = useRef(refreshing); const [status, setStatusState] = useState("idle"); const [internalRefreshing, setInternalRefreshing] = useState(false); const reduce = useReducedMotion(); const pullThreshold = Math.max(24, threshold); const pullLimit = Math.max(maxPull, pullThreshold + 24); const restingDistance = Math.min( Math.max(0, holdDistance), pullThreshold, ); const y = useMotionValue(0); const progress = useTransform(y, [0, pullThreshold], [0, 1]); const indicatorOpacity = useTransform( y, [0, 10, pullThreshold], [0, 0.45, 1], ); const indicatorScale = useTransform(y, [0, pullThreshold], [0.86, 1]); const isRefreshing = refreshing || internalRefreshing; disabledRef.current = disabled; externalRefreshingRef.current = refreshing; refreshingRef.current = isRefreshing; const setStatus = useCallback((next: PullToRefreshStatus) => { if (statusRef.current === next) return; statusRef.current = next; setStatusState(next); }, []); const settle = useCallback( (target: number) => { animationRef.current?.stop(); if (reduce) { y.set(target); return; } animationRef.current = animate(y, target, SPRING_PANEL); }, [reduce, y], ); const updatePull = useCallback( (distance: number) => { if (disabledRef.current || refreshingRef.current) return; animationRef.current?.stop(); const next = resistedDistance(distance, pullLimit); y.set(next); setStatus(next >= pullThreshold ? "ready" : "pulling"); }, [pullLimit, pullThreshold, setStatus, y], ); const runRefresh = useCallback(async () => { if (disabledRef.current || refreshingRef.current) return; setInternalRefreshing(true); setStatus("refreshing"); settle(restingDistance); try { await onRefresh(); } finally { setInternalRefreshing(false); // A synchronous refresh can resolve before React commits the temporary // internal state, so release here instead of relying only on the effect. if (!externalRefreshingRef.current) { setStatus("idle"); settle(0); } } }, [onRefresh, restingDistance, setStatus, settle]); const finishPull = useCallback(() => { const shouldRefresh = y.get() >= pullThreshold && !disabledRef.current && !refreshingRef.current; gestureRef.current = { ...EMPTY_GESTURE }; if (shouldRefresh) { void runRefresh(); return; } setStatus("idle"); settle(0); }, [pullThreshold, runRefresh, setStatus, settle, y]); useEffect(() => { if (isRefreshing) { setStatus("refreshing"); settle(restingDistance); return; } if (statusRef.current === "refreshing") { setStatus("idle"); settle(0); } }, [isRefreshing, restingDistance, setStatus, settle]); useEffect(() => { const root = rootRef.current; if (!root) return; const onTouchStart = (event: TouchEvent) => { if ( event.touches.length !== 1 || root.scrollTop > 0 || disabledRef.current || refreshingRef.current ) { return; } const touch = event.touches[0]; gestureRef.current = { active: true, startX: touch.clientX, startY: touch.clientY, pointerId: null, }; }; const onTouchMove = (event: TouchEvent) => { const gesture = gestureRef.current; const touch = event.touches[0]; if (!gesture.active || !touch) return; const deltaX = touch.clientX - gesture.startX; const deltaY = touch.clientY - gesture.startY; if (root.scrollTop > 0 || deltaY < 0) { gestureRef.current = { ...EMPTY_GESTURE }; return; } if (Math.abs(deltaX) > deltaY) return; event.preventDefault(); updatePull(deltaY); }; const onTouchEnd = () => { if (gestureRef.current.active) finishPull(); }; root.addEventListener("touchstart", onTouchStart, { passive: true }); root.addEventListener("touchmove", onTouchMove, { passive: false }); root.addEventListener("touchend", onTouchEnd); root.addEventListener("touchcancel", onTouchEnd); return () => { root.removeEventListener("touchstart", onTouchStart); root.removeEventListener("touchmove", onTouchMove); root.removeEventListener("touchend", onTouchEnd); root.removeEventListener("touchcancel", onTouchEnd); }; }, [finishPull, updatePull]); useEffect(() => { return () => animationRef.current?.stop(); }, []); const startMousePull = (event: ReactPointerEvent) => { if ( event.pointerType !== "mouse" || event.button !== 0 || event.currentTarget.scrollTop > 0 || disabled || isRefreshing ) { return; } event.currentTarget.setPointerCapture(event.pointerId); gestureRef.current = { active: true, startX: event.clientX, startY: event.clientY, pointerId: event.pointerId, }; }; const moveMousePull = (event: ReactPointerEvent) => { const gesture = gestureRef.current; if (!gesture.active || gesture.pointerId !== event.pointerId) return; const deltaX = event.clientX - gesture.startX; const deltaY = event.clientY - gesture.startY; if (deltaY < 0 || Math.abs(deltaX) > deltaY) return; event.preventDefault(); updatePull(deltaY); }; const label = status === "refreshing" ? refreshingLabel : status === "ready" ? releaseLabel : pullingLabel; return (
{ if (gestureRef.current.pointerId === event.pointerId) finishPull(); }} onPointerCancel={(event) => { if (gestureRef.current.pointerId === event.pointerId) finishPull(); }} className={cn( "relative w-full overflow-y-auto overscroll-contain bg-background", status === "pulling" || status === "ready" ? "cursor-grabbing select-none" : "cursor-grab", (disabled || isRefreshing) && "cursor-default", className, )} > {label} {children}
); }