@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
text/typescript
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,
}
}