'use client' import { useRef, useState, type ReactNode } from 'react' import { cn } from '../../lib/cn' import { ProgressRing } from '../ProgressRing' import { Spinner } from '../Spinner' import { Text } from '../Text' export interface PullToRefreshProps { /** The scrollable content. */ children: ReactNode /** Run the refresh. Await it, and the indicator stays until it settles. */ onRefresh: () => void | Promise /** Pixels the content must travel before the release counts. */ threshold?: number /** Height of the scrolling area. It must scroll for the gesture to make sense. */ className?: string /** Copy shown at each stage. */ labels?: { pull?: string; release?: string; refreshing?: string } /** Render a real Refresh button above the list. Turn it off at your peril. */ showButton?: boolean /** Blocks interaction and dims the control. */ disabled?: boolean } /** Past the threshold the pull gets heavier, so the edge feels elastic. */ function resist(distance: number, threshold: number): number { if (distance <= threshold) return distance return threshold + (distance - threshold) * 0.35 } /** * Drag the top of a list down past a threshold to reload it. * * The gesture only starts when the container is already scrolled to the top, * which is the rule that keeps it from stealing an ordinary scroll — the * common failure of hand-rolled versions, where flicking up mid-list snaps you * to a refresh. Past the threshold the travel is damped rather than clamped, so * the edge feels elastic instead of stuck. * * The indicator tracks the pull as a fraction, so the ring is full at exactly * the point release would fire; nothing has to be guessed. `onRefresh` may * return a promise, and the spinner stays until it settles rather than for an * arbitrary timeout. * * There is always a real Refresh button. A drag is a pointer gesture with no * keyboard equivalent, and a list that can only be reloaded by dragging cannot * be reloaded by everyone. */ export function PullToRefresh({ children, onRefresh, threshold = 72, className, labels, showButton = true, disabled = false, }: PullToRefreshProps) { const scrollRef = useRef(null) const start = useRef(null) const [pull, setPull] = useState(0) const [refreshing, setRefreshing] = useState(false) const copy = { pull: labels?.pull ?? 'Pull to refresh', release: labels?.release ?? 'Release to refresh', refreshing: labels?.refreshing ?? 'Refreshing…', } const armed = pull >= threshold const progress = Math.min(1, pull / threshold) const run = async () => { if (refreshing) return setRefreshing(true) setPull(0) try { await onRefresh() } finally { setRefreshing(false) } } const onPointerDown = (event: React.PointerEvent) => { if (disabled || refreshing) return // Only from the very top: anywhere else this is an ordinary scroll. if ((scrollRef.current?.scrollTop ?? 0) > 0) return start.current = event.clientY } const onPointerMove = (event: React.PointerEvent) => { if (start.current === null) return const distance = event.clientY - start.current if (distance <= 0) { setPull(0) return } event.currentTarget.setPointerCapture(event.pointerId) setPull(resist(distance, threshold)) } const onPointerUp = () => { if (start.current === null) return start.current = null if (armed) void run() else setPull(0) } return (
{showButton && ( )}
{/* The indicator sits behind the content and is uncovered by the pull. */}
{refreshing ? ( ) : ( )} {refreshing ? copy.refreshing : armed ? copy.release : copy.pull}
{children}

{refreshing ? copy.refreshing : ''}

) }