feat: PullToRefresh 풀다운 새로고침 컴포넌트
This commit is contained in:
86
src/components/PullToRefresh.css
Normal file
86
src/components/PullToRefresh.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* PullToRefresh — pull-down-to-refresh wrapper */
|
||||
|
||||
.pull-to-refresh {
|
||||
position: relative;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* Indicator circle */
|
||||
.pull-to-refresh__indicator {
|
||||
position: absolute;
|
||||
top: -48px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-line);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s var(--ease-out);
|
||||
z-index: 10;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.pull-to-refresh__indicator.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.pull-to-refresh__spinner {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-line);
|
||||
border-top-color: var(--neon-cyan);
|
||||
border-radius: 50%;
|
||||
animation: ptr-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ptr-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Arrow chevron */
|
||||
.pull-to-refresh__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transition: transform 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.pull-to-refresh__arrow.is-ready {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.pull-to-refresh__content {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pull-to-refresh__spinner {
|
||||
animation: none;
|
||||
border-top-color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.pull-to-refresh__arrow {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.pull-to-refresh__indicator {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.pull-to-refresh__content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
99
src/components/PullToRefresh.jsx
Normal file
99
src/components/PullToRefresh.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import './PullToRefresh.css';
|
||||
|
||||
const THRESHOLD = 60;
|
||||
const MAX_PULL = 120;
|
||||
const RESISTANCE = 0.5;
|
||||
const CONTENT_SHIFT_FACTOR = 0.3;
|
||||
|
||||
export default function PullToRefresh({ onRefresh, children, className = '' }) {
|
||||
const isMobile = useIsMobile();
|
||||
const [pullY, setPullY] = useState(0);
|
||||
const [state, setState] = useState('idle'); // idle | pulling | ready | refreshing
|
||||
const startYRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (el.scrollTop > 0) return; // only trigger at top
|
||||
startYRef.current = e.touches[0].clientY;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (startYRef.current === null) return;
|
||||
const delta = e.touches[0].clientY - startYRef.current;
|
||||
if (delta <= 0) {
|
||||
setPullY(0);
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
const clamped = Math.min(delta * RESISTANCE, MAX_PULL);
|
||||
setPullY(clamped);
|
||||
setState(clamped >= THRESHOLD ? 'ready' : 'pulling');
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(async () => {
|
||||
if (startYRef.current === null) return;
|
||||
startYRef.current = null;
|
||||
if (state === 'ready') {
|
||||
setState('refreshing');
|
||||
setPullY(THRESHOLD);
|
||||
try {
|
||||
await onRefresh?.();
|
||||
} finally {
|
||||
setState('idle');
|
||||
setPullY(0);
|
||||
}
|
||||
} else {
|
||||
setState('idle');
|
||||
setPullY(0);
|
||||
}
|
||||
}, [state, onRefresh]);
|
||||
|
||||
if (!isMobile) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
const indicatorVisible = state !== 'idle';
|
||||
const contentShift = pullY * CONTENT_SHIFT_FACTOR;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`pull-to-refresh ${className}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div
|
||||
className={`pull-to-refresh__indicator ${indicatorVisible ? 'is-visible' : ''}`}
|
||||
style={{ transform: `translateY(${pullY}px)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{state === 'refreshing' ? (
|
||||
<span className="pull-to-refresh__spinner" />
|
||||
) : (
|
||||
<span className={`pull-to-refresh__arrow ${state === 'ready' ? 'is-ready' : ''}`}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 3v10M4 8l5 5 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="pull-to-refresh__content"
|
||||
style={{ transform: `translateY(${contentShift}px)`, transition: state === 'idle' ? 'transform 0.3s var(--ease-out)' : 'none' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user