"use client"; import { RefreshCw } from "lucide-react"; import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; const EASE = "cubic-bezier(0.23, 1, 0.32, 1)"; const SETTLE_MS = 220; /** Pull distance, in px, that arms a refresh on release. */ const TRIGGER = 64; /** How far the indicator can still be dragged past the trigger, resisting more with distance. */ const MAX_PULL = 96; function useReducedMotion() { const [reduced, setReduced] = useState(false); useEffect(() => { const query = window.matchMedia("(prefers-reduced-motion: reduce)"); const update = () => setReduced(query.matches); update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); return reduced; } export interface PullToRefreshProps { /** Called on release past the trigger distance. The indicator keeps spinning until it resolves. */ onRefresh: () => Promise | void; /** The scrollable content. Give this element a height through className. */ children: ReactNode; className?: string; } /** * A pull-down-to-refresh gesture over scrollable content: the indicator * tracks the finger 1:1, then resists past the trigger distance. Only * starts when the content is already scrolled to the top. */ export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) { const scrollRef = useRef(null); const reduceMotion = useReducedMotion(); const [drag, setDrag] = useState<{ startY: number } | null>(null); const [pull, setPull] = useState(0); const [refreshing, setRefreshing] = useState(false); const onPointerDown = (event: ReactPointerEvent) => { if (refreshing || (scrollRef.current?.scrollTop ?? 0) > 0) return; event.currentTarget.setPointerCapture(event.pointerId); setDrag({ startY: event.clientY }); }; const onPointerMove = (event: ReactPointerEvent) => { if (!drag) return; const raw = event.clientY - drag.startY; if (raw <= 0) { setPull(0); return; } // Tracks 1:1 up to the trigger distance, then resists. const eased = raw <= TRIGGER ? raw : TRIGGER + (raw - TRIGGER) * 0.35; setPull(Math.min(eased, MAX_PULL)); }; const onPointerUp = () => { if (!drag) return; const armed = pull >= TRIGGER; setDrag(null); if (armed) { setPull(TRIGGER); setRefreshing(true); Promise.resolve(onRefresh()).finally(() => { setRefreshing(false); setPull(0); }); } else { setPull(0); } }; const dragging = drag !== null; const armed = pull >= TRIGGER; const transition = dragging || reduceMotion ? "none" : `transform ${SETTLE_MS}ms ${EASE}`; const spin = Math.min(pull / TRIGGER, 1) * 180; return (
{children}
); }