and a reduced‑motion guard. Haptics now honor the toggle and still fall back gracefully on iOS (switch disabled when
navigator.vibrate isn’t available).
What changed
- Haptics preference storage + gating: resources/js/guest/lib/haptics.ts
- Preference hook: resources/js/guest/hooks/useHapticsPreference.ts
- Settings UI toggle in sheet + page: resources/js/guest/components/settings-sheet.tsx, resources/js/guest/pages/
SettingsPage.tsx
- i18n labels: resources/js/guest/i18n/messages.ts
- Tests: resources/js/guest/lib/__tests__/haptics.test.ts
161 lines
4.8 KiB
TypeScript
161 lines
4.8 KiB
TypeScript
import React from 'react';
|
|
import { ArrowDown, Loader2 } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { triggerHaptic } from '../lib/haptics';
|
|
|
|
const MAX_PULL = 96;
|
|
const TRIGGER_PULL = 72;
|
|
const DAMPING = 0.55;
|
|
|
|
type PullToRefreshProps = {
|
|
onRefresh: () => Promise<void> | void;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
pullLabel?: string;
|
|
releaseLabel?: string;
|
|
refreshingLabel?: string;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
export default function PullToRefresh({
|
|
onRefresh,
|
|
disabled = false,
|
|
className,
|
|
pullLabel = 'Pull to refresh',
|
|
releaseLabel = 'Release to refresh',
|
|
refreshingLabel = 'Refreshing…',
|
|
children,
|
|
}: PullToRefreshProps) {
|
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
const startYRef = React.useRef<number | null>(null);
|
|
const pullDistanceRef = React.useRef(0);
|
|
const readyRef = React.useRef(false);
|
|
const [pullDistance, setPullDistance] = React.useState(0);
|
|
const [dragging, setDragging] = React.useState(false);
|
|
const [refreshing, setRefreshing] = React.useState(false);
|
|
|
|
const updatePull = React.useCallback((value: number) => {
|
|
pullDistanceRef.current = value;
|
|
setPullDistance(value);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container || disabled) {
|
|
return;
|
|
}
|
|
|
|
const handleStart = (event: TouchEvent) => {
|
|
if (refreshing || window.scrollY > 0) {
|
|
return;
|
|
}
|
|
startYRef.current = event.touches[0]?.clientY ?? null;
|
|
setDragging(true);
|
|
};
|
|
|
|
const handleMove = (event: TouchEvent) => {
|
|
if (refreshing || startYRef.current === null) {
|
|
return;
|
|
}
|
|
if (window.scrollY > 0) {
|
|
startYRef.current = null;
|
|
updatePull(0);
|
|
setDragging(false);
|
|
return;
|
|
}
|
|
const currentY = event.touches[0]?.clientY ?? 0;
|
|
const delta = currentY - startYRef.current;
|
|
if (delta <= 0) {
|
|
updatePull(0);
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const next = Math.min(MAX_PULL, delta * DAMPING);
|
|
updatePull(next);
|
|
const isReady = next >= TRIGGER_PULL;
|
|
if (isReady && !readyRef.current) {
|
|
readyRef.current = true;
|
|
triggerHaptic('selection');
|
|
} else if (!isReady && readyRef.current) {
|
|
readyRef.current = false;
|
|
}
|
|
};
|
|
|
|
const handleEnd = async () => {
|
|
if (startYRef.current === null) {
|
|
return;
|
|
}
|
|
startYRef.current = null;
|
|
setDragging(false);
|
|
readyRef.current = false;
|
|
|
|
if (pullDistanceRef.current >= TRIGGER_PULL) {
|
|
triggerHaptic('medium');
|
|
setRefreshing(true);
|
|
updatePull(TRIGGER_PULL);
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
setRefreshing(false);
|
|
updatePull(0);
|
|
}
|
|
return;
|
|
}
|
|
|
|
updatePull(0);
|
|
};
|
|
|
|
container.addEventListener('touchstart', handleStart, { passive: true });
|
|
container.addEventListener('touchmove', handleMove, { passive: false });
|
|
container.addEventListener('touchend', handleEnd);
|
|
container.addEventListener('touchcancel', handleEnd);
|
|
|
|
return () => {
|
|
container.removeEventListener('touchstart', handleStart);
|
|
container.removeEventListener('touchmove', handleMove);
|
|
container.removeEventListener('touchend', handleEnd);
|
|
container.removeEventListener('touchcancel', handleEnd);
|
|
};
|
|
}, [disabled, onRefresh, refreshing, updatePull]);
|
|
|
|
const progress = Math.min(pullDistance / TRIGGER_PULL, 1);
|
|
const ready = pullDistance >= TRIGGER_PULL;
|
|
const indicatorLabel = refreshing
|
|
? refreshingLabel
|
|
: ready
|
|
? releaseLabel
|
|
: pullLabel;
|
|
|
|
return (
|
|
<div ref={containerRef} className={cn('relative', className)}>
|
|
<div
|
|
className="pointer-events-none absolute left-0 right-0 top-2 flex h-10 items-center justify-center"
|
|
style={{
|
|
opacity: progress,
|
|
transform: `translateY(${Math.min(pullDistance, TRIGGER_PULL) - 48}px)`,
|
|
transition: dragging ? 'none' : 'transform 200ms ease-out, opacity 200ms ease-out',
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 rounded-full border border-white/30 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-slate-900/80 dark:text-slate-100">
|
|
{refreshing ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
) : (
|
|
<ArrowDown
|
|
className={cn('h-4 w-4 transition-transform duration-200', ready && 'rotate-180')}
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
<span>{indicatorLabel}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={cn('will-change-transform', !dragging && 'transition-transform duration-200 ease-out')}
|
|
style={{ transform: `translateY(${pullDistance}px)` }}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|