SPB Git

Repository Public

pull-to-refresh.tsx 4.0 KB 88 lines tsx

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
Files main
Menu

Theme