- useMediaQuery: SSR-safe hook with addEventListener for live breakpoint updates - usePullToRefresh: touch gesture hook with 64px threshold, haptic feedback via navigator.vibrate - PullToRefresh: visual wrapper with Loader2 spinner, pull/release text indicators
81 lines
2.5 KiB
TypeScript
81 lines
2.5 KiB
TypeScript
import { type ReactNode } from "react";
|
|
import { Loader2 } from "lucide-react";
|
|
import { usePullToRefresh } from "../hooks/usePullToRefresh";
|
|
|
|
const THRESHOLD = 64;
|
|
|
|
interface PullToRefreshProps {
|
|
children: ReactNode;
|
|
onRefresh: () => Promise<void> | void;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Touch gesture wrapper that triggers a refresh after pulling down 64px.
|
|
* Shows a visual indicator with "Pull to refresh" / "Release to refresh" text.
|
|
*/
|
|
export function PullToRefresh({ children, onRefresh, enabled = true }: PullToRefreshProps) {
|
|
const { containerRef, pullDistance, isRefreshing, setIsRefreshing } = usePullToRefresh({
|
|
onRefresh: () => {
|
|
const result = onRefresh();
|
|
if (result instanceof Promise) {
|
|
result.finally(() => setIsRefreshing(false));
|
|
} else {
|
|
setIsRefreshing(false);
|
|
}
|
|
},
|
|
threshold: THRESHOLD,
|
|
maxPull: 96,
|
|
enabled,
|
|
});
|
|
|
|
const isVisible = pullDistance > 0 || isRefreshing;
|
|
const indicatorOpacity = isRefreshing ? 1 : pullDistance / THRESHOLD;
|
|
const indicatorTranslateY = isRefreshing
|
|
? 0
|
|
: Math.min(pullDistance - THRESHOLD / 2, 0);
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative h-full overflow-y-auto">
|
|
{/* Pull indicator */}
|
|
{isVisible && (
|
|
<div
|
|
className="absolute left-0 right-0 top-0 z-10 flex flex-col items-center justify-end pb-2"
|
|
style={{
|
|
height: isRefreshing ? 48 : Math.max(pullDistance, 0),
|
|
opacity: indicatorOpacity,
|
|
transform: isRefreshing ? undefined : `translateY(${indicatorTranslateY}px)`,
|
|
}}
|
|
aria-live="polite"
|
|
aria-label={
|
|
isRefreshing
|
|
? "Refreshing"
|
|
: pullDistance >= THRESHOLD
|
|
? "Release to refresh"
|
|
: "Pull to refresh"
|
|
}
|
|
>
|
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
{!isRefreshing && (
|
|
<span className="mt-1 text-xs text-muted-foreground">
|
|
{pullDistance >= THRESHOLD ? "Release to refresh" : "Pull to refresh"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content — pushed down by pull distance when pulling */}
|
|
<div
|
|
style={{
|
|
transform:
|
|
pullDistance > 0
|
|
? `translateY(${Math.min(pullDistance, 96)}px)`
|
|
: undefined,
|
|
transition: pullDistance === 0 ? "transform 200ms ease-out" : undefined,
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|