All files / src/hooks usePullToRefresh.ts

0% Statements 0/71
0% Branches 0/36
0% Functions 0/10
0% Lines 0/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                                                                                                                                                                                                                         
import { useEffect, useRef, useState } from 'react'
 
const PULL_THRESHOLD = 70
const ACTIVATION_DISTANCE = 10
const MAX_PULL = PULL_THRESHOLD * 1.6
 
function isAtTop(): boolean {
  return window.scrollY <= 0 && document.documentElement.scrollTop <= 0
}
 
// If the user starts the gesture inside an internal scroller that is not at
// scrollTop 0, we must not hijack it for refresh — scrolling within the
// scroller stays a normal scroll.
function hasScrolledScrollableAncestor(target: Element | null): boolean {
  let node: Element | null = target
  while (node && node !== document.body && node !== document.documentElement) {
    const style = window.getComputedStyle(node)
    const overflowY = style.overflowY
    if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
      if (node.scrollTop > 0) return true
    }
    node = node.parentElement
  }
  return false
}
 
export interface PullToRefreshState {
  pullDistance: number
  isPulling: boolean
  isReleasing: boolean
  threshold: number
}
 
export function usePullToRefresh(): PullToRefreshState {
  const [pullDistance, setPullDistance] = useState(0)
  const [isPulling, setIsPulling] = useState(false)
  const [isReleasing, setIsReleasing] = useState(false)
 
  const startYRef = useRef<number | null>(null)
  const distanceRef = useRef(0)
  const pullingRef = useRef(false)
 
  useEffect(() => {
    function reset() {
      startYRef.current = null
      distanceRef.current = 0
      pullingRef.current = false
      setPullDistance(0)
      setIsPulling(false)
    }
 
    function onTouchStart(e: TouchEvent) {
      if (e.touches.length !== 1) return
      if (!isAtTop()) return
      const target = e.target as Element | null
      if (!target) return
      if (target.closest('input, textarea, select, [contenteditable="true"], [data-no-ptr]')) return
      if (hasScrolledScrollableAncestor(target)) return
      startYRef.current = e.touches[0].clientY
    }
 
    function onTouchMove(e: TouchEvent) {
      if (startYRef.current === null) return
      if (e.touches.length !== 1) {
        reset()
        return
      }
      const dy = e.touches[0].clientY - startYRef.current
      if (dy <= 0) {
        reset()
        return
      }
      if (dy < ACTIVATION_DISTANCE) return
      // Block native rubber-band so the indicator owns the gesture.
      if (e.cancelable) e.preventDefault()
      const visible = Math.min(dy, MAX_PULL)
      distanceRef.current = visible
      pullingRef.current = true
      setPullDistance(visible)
      setIsPulling(true)
    }
 
    function onTouchEnd() {
      if (pullingRef.current && distanceRef.current >= PULL_THRESHOLD) {
        // Show the "releasing" state briefly so the user gets visual confirmation
        // before the page reloads. setTimeout lets React paint one frame.
        setIsReleasing(true)
        setTimeout(() => window.location.reload(), 80)
        return
      }
      reset()
    }
 
    document.addEventListener('touchstart', onTouchStart, { passive: true })
    document.addEventListener('touchmove', onTouchMove, { passive: false })
    document.addEventListener('touchend', onTouchEnd, { passive: true })
    document.addEventListener('touchcancel', reset, { passive: true })
 
    return () => {
      document.removeEventListener('touchstart', onTouchStart)
      document.removeEventListener('touchmove', onTouchMove as EventListener)
      document.removeEventListener('touchend', onTouchEnd)
      document.removeEventListener('touchcancel', reset as EventListener)
    }
  }, [])
 
  return { pullDistance, isPulling, isReleasing, threshold: PULL_THRESHOLD }
}
 
← Back to Dashboard