129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
interface PullToRefreshProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
const THRESHOLD = 80; // px of pull distance to trigger refresh
|
|
const MAX_PULL = 140; // px before rubberbanding clamps
|
|
const REFRESH_INDICATOR_HEIGHT = 56;
|
|
|
|
/**
|
|
* Custom pull-to-refresh — no third-party library.
|
|
*
|
|
* Only engages when the page is scrolled to the top (`window.scrollY === 0`).
|
|
* Uses rubberband resistance so the pull feels heavy past a comfortable
|
|
* range, and clamps the visible offset at MAX_PULL. The release animation
|
|
* snaps back to 0 (or to REFRESH_INDICATOR_HEIGHT if a refresh is in
|
|
* flight). Calls `router.refresh()` so the server component re-renders
|
|
* with fresh data.
|
|
*
|
|
* Respects `prefers-reduced-motion: reduce` — the transition is disabled
|
|
* and the offset still updates, but the snap-back doesn't animate. The
|
|
* indicator spinner animation is also dropped under reduced motion.
|
|
*/
|
|
export function PullToRefresh({ children }: PullToRefreshProps) {
|
|
const router = useRouter();
|
|
const startY = useRef<number | null>(null);
|
|
const pulling = useRef(false);
|
|
const [offset, setOffset] = useState(0);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
setPrefersReducedMotion(mq.matches);
|
|
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
|
|
mq.addEventListener("change", handler);
|
|
return () => mq.removeEventListener("change", handler);
|
|
}, []);
|
|
|
|
function onTouchStart(e: React.TouchEvent<HTMLDivElement>) {
|
|
if (refreshing) return;
|
|
// Only start a pull if the page is scrolled to the top — pulling
|
|
// mid-scroll would hijack normal scroll.
|
|
if (window.scrollY > 0) return;
|
|
startY.current = e.touches[0].clientY;
|
|
pulling.current = true;
|
|
}
|
|
|
|
function onTouchMove(e: React.TouchEvent<HTMLDivElement>) {
|
|
if (!pulling.current || startY.current === null) return;
|
|
const dy = e.touches[0].clientY - startY.current;
|
|
if (dy <= 0) {
|
|
setOffset(0);
|
|
return;
|
|
}
|
|
// Rubberband: resistance curve so it gets harder to pull further.
|
|
const resistance = 1 - Math.min(1, dy / 400);
|
|
const next = Math.min(MAX_PULL, dy * resistance);
|
|
setOffset(next);
|
|
}
|
|
|
|
async function onTouchEnd() {
|
|
if (!pulling.current) return;
|
|
pulling.current = false;
|
|
startY.current = null;
|
|
if (offset >= THRESHOLD) {
|
|
setRefreshing(true);
|
|
setOffset(REFRESH_INDICATOR_HEIGHT);
|
|
router.refresh();
|
|
// Give the refresh a moment to complete visually. The actual data
|
|
// fetch happens on the server; this is just the "spinner shown
|
|
// for a beat" feel.
|
|
await new Promise((r) => setTimeout(r, 600));
|
|
setRefreshing(false);
|
|
setOffset(0);
|
|
} else {
|
|
setOffset(0);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
onTouchCancel={onTouchEnd}
|
|
style={{
|
|
transform: `translateY(${offset}px)`,
|
|
transition: pulling.current || prefersReducedMotion ? "none" : "transform 200ms var(--ease-apple)",
|
|
overscrollBehavior: "contain",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{offset > 0 && (
|
|
<div
|
|
aria-hidden="true"
|
|
className="flex items-center justify-center"
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: `${REFRESH_INDICATOR_HEIGHT}px`,
|
|
transform: `translateY(-${REFRESH_INDICATOR_HEIGHT}px)`,
|
|
}}
|
|
>
|
|
<div
|
|
className="rounded-full"
|
|
style={{
|
|
width: "32px",
|
|
height: "32px",
|
|
border: "3px solid var(--color-surface-3)",
|
|
borderTopColor: "var(--color-accent)",
|
|
animation: refreshing && !prefersReducedMotion ? "spin 0.8s linear infinite" : "none",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PullToRefresh;
|