feat(admin): add PullToRefresh (custom, prefers-reduced-motion aware)
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"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;
|
||||
Reference in New Issue
Block a user