feat: implement pull-to-refresh functionality across ClientLibraryPage, ClientSeriesPage, and ClientHomePage for improved user experience

This commit is contained in:
Julien Froidefond
2025-10-18 22:19:50 +02:00
parent 8b4e117ead
commit 0806487fe7
6 changed files with 344 additions and 18 deletions

View File

@@ -0,0 +1,183 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface UsePullToRefreshOptions {
onRefresh: () => Promise<void>;
threshold?: number;
resistance?: number;
enabled?: boolean;
}
interface PullToRefreshState {
isPulling: boolean;
isRefreshing: boolean;
pullDistance: number;
canRefresh: boolean;
isHiding: boolean;
}
export function usePullToRefresh({
onRefresh,
threshold = 80,
resistance = 0.5,
enabled = true,
}: UsePullToRefreshOptions) {
const [state, setState] = useState<PullToRefreshState>({
isPulling: false,
isRefreshing: false,
pullDistance: 0,
canRefresh: false,
isHiding: false,
});
const startY = useRef(0);
const currentY = useRef(0);
const startScrollTop = useRef(0);
const isValidPull = useRef(false);
const isRefreshingRef = useRef(false);
useEffect(() => {
if (!enabled) return;
const handleTouchStart = (e: TouchEvent) => {
if (isRefreshingRef.current) return;
// Ignorer les touches sur les éléments interactifs (boutons, liens, menu, etc.)
const target = e.target as HTMLElement;
if (
target.closest('button') ||
target.closest('a') ||
target.closest('[role="button"]') ||
target.closest('nav') ||
target.closest('header') ||
target.closest('[data-no-pull-refresh]')
) {
isValidPull.current = false;
return;
}
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
startScrollTop.current = scrollTop;
// Ne démarrer que si on est vraiment en haut de la page
if (scrollTop === 0) {
startY.current = e.touches[0].clientY;
currentY.current = e.touches[0].clientY;
isValidPull.current = true;
} else {
isValidPull.current = false;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (!isValidPull.current || isRefreshingRef.current) return;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
currentY.current = e.touches[0].clientY;
const deltaY = currentY.current - startY.current;
// Vérifier qu'on est toujours en haut ET qu'on tire vers le bas
if (scrollTop === 0 && deltaY > 0) {
const pullDistance = Math.min(deltaY * resistance, threshold * 1.5);
const canRefresh = pullDistance >= threshold;
setState(prev => ({
...prev,
isPulling: true,
pullDistance,
canRefresh,
}));
// Empêcher le scroll par défaut quand on tire vers le bas
if (pullDistance > 10) {
e.preventDefault();
}
} else if (scrollTop > 0 || deltaY < 0) {
// Si on scrolle ou qu'on tire vers le haut, annuler
isValidPull.current = false;
setState(prev => ({
...prev,
isPulling: false,
pullDistance: 0,
canRefresh: false,
}));
}
};
const handleTouchEnd = async () => {
if (!isValidPull.current || isRefreshingRef.current) {
isValidPull.current = false;
setState(prev => ({
...prev,
isPulling: false,
pullDistance: 0,
canRefresh: false,
}));
return;
}
const shouldRefresh = state.canRefresh;
setState(prev => ({
...prev,
isPulling: false,
}));
if (shouldRefresh) {
isRefreshingRef.current = true;
setState(prev => ({
...prev,
isRefreshing: true,
pullDistance: 0,
}));
try {
await onRefresh();
} catch (error) {
console.error("Pull to refresh error:", error);
} finally {
isRefreshingRef.current = false;
// Activer l'animation de disparition
setState(prev => ({
...prev,
isHiding: true,
}));
// Attendre la fin de l'animation avant de masquer complètement
setTimeout(() => {
setState(prev => ({
...prev,
isRefreshing: false,
isHiding: false,
}));
}, 300); // Durée de l'animation en ms
}
} else {
// Animation de retour
setState(prev => ({
...prev,
pullDistance: 0,
canRefresh: false,
}));
}
isValidPull.current = false;
};
document.addEventListener("touchstart", handleTouchStart, { passive: false });
document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd);
return () => {
document.removeEventListener("touchstart", handleTouchStart);
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
};
}, [state.isPulling, state.canRefresh, onRefresh, threshold, resistance, enabled]);
return {
...state,
progress: Math.min(state.pullDistance / threshold, 1),
};
}