Repository Public
1'use client';2import * as React from 'react';3import { animate, motion, useMotionValue, useTransform } from 'motion/react';4import { cn } from '@/lib/cn';5import { haptic } from '@/lib/haptic';67const THRESHOLD = 64;8/**9 * Scroll container with pull-to-refresh (touch only). The indicator is an arc that fills with10 * the pull and spins while `onRefresh` is pending. Without touch it is a plain scroll area.11 */12export function PullToRefresh({ onRefresh, children, className, testId }: { onRefresh: () => Promise<unknown>; children: React.ReactNode; className?: string; testId?: string }) {13 const ref = React.useRef<HTMLDivElement>(null);14 const pull = useMotionValue(0);15 const [refreshing, setRefreshing] = React.useState(false);16 const busy = React.useRef(false);17 const progress = useTransform(pull, [0, THRESHOLD], [0, 1], { clamp: true });18 const dash = useTransform(progress, (p) => `${p * 50} 100`);19 const opacity = useTransform(pull, [0, 16, THRESHOLD], [0, 0.4, 1]);20 const onRefreshRef = React.useRef(onRefresh);21 onRefreshRef.current = onRefresh;2223 React.useEffect(() => {24 const el = ref.current;25 if (!el) return;26 let startY: number | null = null;27 let armed = false;28 const start = (e: TouchEvent) => {29 startY = el.scrollTop <= 0 && !busy.current && e.touches.length === 1 ? (e.touches[0]?.clientY ?? null) : null;30 armed = false;31 };32 const move = (e: TouchEvent) => {33 if (startY === null) return;34 const dy = (e.touches[0]?.clientY ?? startY) - startY;35 if (dy <= 0 || el.scrollTop > 0) {36 pull.set(0);37 return;38 }39 e.preventDefault(); // we own this gesture: no native overscroll40 const v = Math.min(110, dy * 0.5);41 pull.set(v);42 if (!armed && v >= THRESHOLD) {43 armed = true;44 haptic(10);45 } else if (armed && v < THRESHOLD) armed = false;46 };47 const end = () => {48 if (startY === null) return;49 startY = null;50 if (pull.get() >= THRESHOLD) {51 busy.current = true;52 setRefreshing(true);53 void animate(pull, 52, { type: 'spring', stiffness: 400, damping: 34 });54 const min = new Promise((r) => setTimeout(r, 450)); // long enough to be seen, never a fake delay beyond that55 void Promise.allSettled([onRefreshRef.current(), min]).then(() => {56 busy.current = false;57 setRefreshing(false);58 void animate(pull, 0, { type: 'spring', stiffness: 380, damping: 36 });59 });60 } else void animate(pull, 0, { type: 'spring', stiffness: 380, damping: 36 });61 };62 el.addEventListener('touchstart', start, { passive: true });63 el.addEventListener('touchmove', move, { passive: false });64 el.addEventListener('touchend', end);65 el.addEventListener('touchcancel', end);66 return () => {67 el.removeEventListener('touchstart', start);68 el.removeEventListener('touchmove', move);69 el.removeEventListener('touchend', end);70 el.removeEventListener('touchcancel', end);71 };72 }, [pull]);7374 return (75 <div ref={ref} data-testid={testId} className={cn('relative overflow-y-auto overscroll-y-contain', className)}>76 <motion.div aria-hidden={!refreshing} role={refreshing ? 'status' : undefined} aria-label={refreshing ? 'Refreshing' : undefined} style={{ opacity, y: useTransform(pull, (v) => v - 44) }} className="pointer-events-none absolute inset-x-0 top-0 z-10 flex justify-center">77 <span className="flex size-9 items-center justify-center rounded-full bg-surface shadow-float">78 <svg viewBox="0 0 36 36" className={cn('size-5', refreshing && 'animate-spin')}>79 <circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--color-line-2)" strokeWidth="3.5" />80 <motion.circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--color-accent)" strokeWidth="3.5" strokeLinecap="round" pathLength={100} strokeDasharray={refreshing ? '28 100' : dash} transform="rotate(-90 18 18)" />81 </svg>82 </span>83 </motion.div>84 <motion.div style={{ y: pull }}>{children}</motion.div>85 </div>86 );87}88