feat(admin): add PullToRefresh (custom, prefers-reduced-motion aware)

This commit is contained in:
Tyler
2026-06-17 14:45:08 -06:00
parent 9b9643e2c7
commit 1577f6363b
2 changed files with 136 additions and 0 deletions
+8
View File
@@ -294,6 +294,14 @@ select:-webkit-autofill:focus {
100% { transform: translateX(400%); }
}
/* Spinner for the PullToRefresh indicator. The indicator uses
* `border-top-color` to give the impression of one bright edge rotating.
* Reduced-motion users get `animation: none` from the component, so this
* keyframe doesn't need a motion-guard. */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Reduce motion: respect the user's OS-level preference. */
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
* but flattens everything that fights vestibular comfort. This block wipes:
+128
View File
@@ -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;