UNPKG

@shopify/shop-minis-react

Version:

React component library for Shopify Shop Minis with Tailwind CSS v4 support (source-only, requires TypeScript)

287 lines (237 loc) 7.15 kB
import {useCallback, useEffect, useRef, useState} from 'react' const DEFAULT_REFRESH_PULL_THRESHOLD = 200 const ANIMATION_DURATION = 400 export interface UsePullToRefreshOptions { onRefresh?: () => Promise<void> threshold?: number indicatorThreshold?: number enabled?: boolean } export interface PullToRefreshState { isPulling: boolean pullDistance: number canRefresh: boolean } export function usePullToRefresh({ onRefresh, threshold = DEFAULT_REFRESH_PULL_THRESHOLD, indicatorThreshold = 0, enabled = false, }: UsePullToRefreshOptions) { const [state, setState] = useState<PullToRefreshState>({ isPulling: false, pullDistance: 0, canRefresh: false, }) const startY = useRef(0) const currentY = useRef(0) const containerRef = useRef<HTMLElement | null>(null) const animationRef = useRef<number | null>(null) const isRefreshingRef = useRef(false) const handleTouchStart = useCallback( (event: TouchEvent) => { if (!enabled || !containerRef.current || isRefreshingRef.current) return if (animationRef.current) { cancelAnimationFrame(animationRef.current) animationRef.current = null } setState(prev => { const container = containerRef.current if (!container) return prev const isAtTop = container.scrollTop <= 0 if (isAtTop) { const touch = event.touches[0] startY.current = touch.clientY } else { startY.current = 0 return { ...prev, isPulling: false, pullDistance: 0, canRefresh: false, } } return prev }) }, [enabled] ) const handleMove = useCallback( (clientY: number, preventDefault?: () => void) => { if (!enabled || !containerRef.current || startY.current === 0) { return } setState(prev => { if (isRefreshingRef.current) { return prev } const container = containerRef.current if (!container) return prev const isAtTop = container.scrollTop <= 0 currentY.current = clientY const deltaY = currentY.current - startY.current const isValidPull = isAtTop && deltaY > 0 if (isValidPull) { const pullDistance = deltaY const shouldShowIndicator = pullDistance >= indicatorThreshold if (shouldShowIndicator && preventDefault) { preventDefault() } if (shouldShowIndicator || prev.isPulling) { const elasticDistance = pullDistance > threshold ? threshold + (pullDistance - threshold) * 0.5 : pullDistance return { ...prev, isPulling: shouldShowIndicator, pullDistance: elasticDistance, canRefresh: pullDistance >= threshold, } } return prev } else if (prev.isPulling) { return { ...prev, isPulling: false, pullDistance: 0, canRefresh: false, } } return prev }) }, [threshold, indicatorThreshold, enabled] ) const handleTouchMove = useCallback( (event: TouchEvent) => { if (isRefreshingRef.current) return const touch = event.touches[0] if (!touch) return if (startY.current !== 0) { handleMove(touch.clientY, () => event.preventDefault()) } }, [handleMove] ) const resetRefreshingState = useCallback(() => { isRefreshingRef.current = false }, []) const animatePullDistanceToZero = useCallback((fromDistance?: number) => { if (animationRef.current) { cancelAnimationFrame(animationRef.current) animationRef.current = null } let startDistance = fromDistance if (startDistance === undefined) { setState(prev => { startDistance = prev.pullDistance return prev }) } if (!startDistance || startDistance <= 0) { setState(prev => ({ ...prev, pullDistance: 0, canRefresh: false, isPulling: false, })) return } const duration = ANIMATION_DURATION const startTime = Date.now() const animate = () => { const elapsed = Date.now() - startTime const progress = Math.min(elapsed / duration, 1) const easeOut = 1 - (1 - progress) ** 3 const currentDistance = startDistance! * (1 - easeOut) setState(prev => ({ ...prev, pullDistance: currentDistance, canRefresh: progress < 1 ? prev.canRefresh : false, })) if (progress < 1) { animationRef.current = requestAnimationFrame(animate) } else { animationRef.current = null setState(prev => ({ ...prev, pullDistance: 0, canRefresh: false, isPulling: false, })) } } animationRef.current = requestAnimationFrame(animate) }, []) const handleEnd = useCallback(async () => { if (isRefreshingRef.current) { startY.current = 0 return } startY.current = 0 if (animationRef.current) { cancelAnimationFrame(animationRef.current) animationRef.current = null } let shouldRefresh = false let currentPullDistance = 0 const wasRefreshing = isRefreshingRef.current setState(prev => { shouldRefresh = prev.canRefresh && !wasRefreshing currentPullDistance = prev.pullDistance return { ...prev, isPulling: false, } }) if (currentPullDistance <= 0) { setState(prev => ({ ...prev, pullDistance: 0, canRefresh: false, })) return } if (shouldRefresh && onRefresh) { isRefreshingRef.current = true try { await onRefresh() } catch (error) { console.error('Pull to refresh failed:', error) } resetRefreshingState() } animatePullDistanceToZero(currentPullDistance) }, [onRefresh, animatePullDistanceToZero, resetRefreshingState]) const bindToElement = useCallback( (element: HTMLElement | null) => { if (!element) return containerRef.current = element element.addEventListener('touchstart', handleTouchStart, {passive: false}) element.addEventListener('touchmove', handleTouchMove, {passive: false}) element.addEventListener('touchend', handleEnd) element.addEventListener('touchcancel', handleEnd) return () => { element.removeEventListener('touchstart', handleTouchStart) element.removeEventListener('touchmove', handleTouchMove) element.removeEventListener('touchend', handleEnd) element.removeEventListener('touchcancel', handleEnd) } }, [handleTouchStart, handleTouchMove, handleEnd] ) useEffect(() => { return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current) animationRef.current = null } } }, []) return { state, bindToElement, containerRef, } }