import { motion, useMotionValue, useTransform } from "framer-motion"; import { RefreshCw } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import "./PullToRefresh.css"; interface PullToRefreshProps { onRefresh: () => Promise | void; isRefreshing?: boolean; children: React.ReactNode; threshold?: number; } export const PullToRefresh: React.FC = ({ onRefresh, isRefreshing = false, children, threshold = 60, }) => { const [isActive, setIsActive] = useState(false); const [isPulling, setIsPulling] = useState(false); const containerRef = useRef(null); const startY = useRef(0); const y = useMotionValue(0); const opacity = useTransform(y, [0, threshold], [0, 1]); const scale = useTransform(y, [0, threshold], [0.5, 1]); const rotate = useTransform(y, [0, threshold], [0, 180]); const handleTouchStart = useCallback( (e: TouchEvent) => { // Very strict check - must be at absolute top const windowScroll = window.pageYOffset || window.scrollY || 0; const htmlScroll = document.documentElement.scrollTop; const bodyScroll = document.body.scrollTop; const containerScroll = containerRef.current?.scrollTop || 0; const parentScroll = containerRef.current?.parentElement?.scrollTop || 0; const maxScroll = Math.max( windowScroll, htmlScroll, bodyScroll, containerScroll, parentScroll ); if (maxScroll > 0 || isRefreshing) { setIsPulling(false); setIsActive(false); return; } startY.current = e.touches[0].clientY; setIsPulling(true); }, [isRefreshing] ); const handleTouchMove = useCallback( (e: TouchEvent) => { if (!isPulling) return; // Continuously check if we're still at the top during the gesture const windowScroll = window.pageYOffset || window.scrollY || 0; const htmlScroll = document.documentElement.scrollTop; const bodyScroll = document.body.scrollTop; const containerScroll = containerRef.current?.scrollTop || 0; const parentScroll = containerRef.current?.parentElement?.scrollTop || 0; const maxScroll = Math.max( windowScroll, htmlScroll, bodyScroll, containerScroll, parentScroll ); if (maxScroll > 10) { // Cancel pull-to-refresh if we've scrolled away from top setIsPulling(false); setIsActive(false); y.set(0); return; } const currentY = e.touches[0].clientY; const pullDistance = currentY - startY.current; if (pullDistance > 0) { // Only prevent default when the event is cancelable if (e.cancelable) { e.preventDefault(); } const dampedDistance = Math.min(pullDistance * 0.5, threshold * 1.2); y.set(dampedDistance); if (dampedDistance >= threshold && !isActive) { setIsActive(true); // Only vibrate if user activation is available and vibrate is supported if (navigator.vibrate && navigator.userActivation?.hasBeenActive) { navigator.vibrate(50); } } else if (dampedDistance < threshold && isActive) { setIsActive(false); } } else { // Reset if pulling up y.set(0); setIsActive(false); } }, [isPulling, threshold, isActive, y] ); const handleTouchEnd = useCallback(async () => { if (!isPulling) return; setIsPulling(false); if (isActive && y.get() >= threshold && !isRefreshing) { try { await onRefresh(); } catch (error) { console.error("Refresh failed:", error); } } // Always reset state setIsActive(false); y.set(0); startY.current = 0; }, [isPulling, isActive, threshold, isRefreshing, onRefresh, y]); useEffect(() => { const container = containerRef.current; if (!container) return; // Use passive: false for touchmove to allow preventDefault container.addEventListener("touchstart", handleTouchStart, { passive: true, }); container.addEventListener("touchmove", handleTouchMove, { passive: false, }); container.addEventListener("touchend", handleTouchEnd, { passive: true }); return () => { container.removeEventListener("touchstart", handleTouchStart); container.removeEventListener("touchmove", handleTouchMove); container.removeEventListener("touchend", handleTouchEnd); }; }, [handleTouchStart, handleTouchMove, handleTouchEnd]); return (
{isPulling && ( )}
{children}
); };