nexus/ui/src/components/PullToRefresh.tsx
Nexus Dev a8cbc090fd feat(26-02): create useMediaQuery hook, usePullToRefresh hook, and PullToRefresh component
- 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
2026-04-02 15:08:51 +00:00

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>
);
}