react-sway 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -0
- package/dist/index.cjs +314 -209
- package/dist/index.d.cts +55 -2
- package/dist/index.d.ts +55 -2
- package/dist/index.js +315 -210
- package/package.json +18 -12
- package/src/ReactSway.tsx +445 -247
- package/src/__tests__/ReactSway.test.tsx +508 -0
- package/src/__tests__/setup.ts +20 -0
- package/src/index.ts +5 -3
package/src/ReactSway.tsx
CHANGED
|
@@ -1,248 +1,463 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const INACTIVITY_DELAY = 2000; // ms
|
|
6
|
-
const FRICTION = 0.95;
|
|
7
|
-
const MAX_DELTA_TIME = 3; // Cap deltaTime to prevent physics breaking
|
|
3
|
+
/** Velocity applied per arrow key press (pixels). */
|
|
4
|
+
const ARROW_KEY_VELOCITY = 15;
|
|
8
5
|
|
|
9
|
-
|
|
6
|
+
/** Default friction coefficient applied to velocity each frame. */
|
|
7
|
+
const DEFAULT_FRICTION = 0.95;
|
|
8
|
+
|
|
9
|
+
/** Default IntersectionObserver rootMargin for lazy visibility detection. */
|
|
10
|
+
const DEFAULT_LAZY_ROOT_MARGIN = '100px';
|
|
11
|
+
|
|
12
|
+
/** Default IntersectionObserver threshold for lazy visibility detection. */
|
|
13
|
+
const DEFAULT_LAZY_THRESHOLD = 0.01;
|
|
14
|
+
|
|
15
|
+
/** Default delay in milliseconds before auto-scroll resumes after user interaction. */
|
|
16
|
+
const DEFAULT_RESUME_DELAY = 2000;
|
|
17
|
+
|
|
18
|
+
/** Default scroll speed in pixels per frame at 60fps. */
|
|
19
|
+
const DEFAULT_SPEED = 0.5;
|
|
20
|
+
|
|
21
|
+
/** Number of stacked content groups used to build the seamless loop. */
|
|
22
|
+
const LOOP_SEGMENTS = 3;
|
|
23
|
+
|
|
24
|
+
/** Maximum deltaTime cap to prevent physics instability during frame drops. */
|
|
25
|
+
const MAX_DELTA_TIME = 3;
|
|
26
|
+
|
|
27
|
+
/** Maximum allowed velocity magnitude to prevent runaway scrolling. */
|
|
28
|
+
const MAX_VELOCITY = 150;
|
|
29
|
+
|
|
30
|
+
/** Duration of a single frame at 60fps in milliseconds. */
|
|
31
|
+
const MS_PER_FRAME_60FPS = 16.667;
|
|
32
|
+
|
|
33
|
+
/** Speed multiplier when user prefers reduced motion (25% of normal). */
|
|
34
|
+
const REDUCED_MOTION_SPEED_FACTOR = 0.25;
|
|
35
|
+
|
|
36
|
+
/** Debounce delay in milliseconds for ResizeObserver callbacks. */
|
|
37
|
+
const RESIZE_DEBOUNCE_MS = 150;
|
|
38
|
+
|
|
39
|
+
/** Multiplier applied to wheel deltaY to convert to scroll velocity. */
|
|
40
|
+
const WHEEL_VELOCITY_MULTIPLIER = 0.3;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Props for the ReactSway infinite scrolling component.
|
|
44
|
+
*/
|
|
45
|
+
export interface ReactSwayProps {
|
|
46
|
+
/** Enable/disable auto-scrolling. @default true */
|
|
47
|
+
autoScroll?: boolean;
|
|
48
|
+
/** Content elements to render in the infinite scroll container. */
|
|
10
49
|
children: ReactNode;
|
|
50
|
+
/** Auto-scroll direction. @default 'up' */
|
|
51
|
+
direction?: 'down' | 'up';
|
|
52
|
+
/** Enable mouse/touch drag interaction. @default true */
|
|
53
|
+
draggable?: boolean;
|
|
54
|
+
/** Momentum decay coefficient (0-1, lower = more friction). @default 0.95 */
|
|
55
|
+
friction?: number;
|
|
56
|
+
/** Enable keyboard controls (Space, ArrowUp/Down, Home/End). @default true */
|
|
57
|
+
keyboard?: boolean;
|
|
58
|
+
/** Enable lazy visibility detection via IntersectionObserver. @default true */
|
|
59
|
+
lazy?: boolean;
|
|
60
|
+
/** IntersectionObserver rootMargin for lazy visibility detection. @default '100px' */
|
|
61
|
+
lazyRootMargin?: string;
|
|
62
|
+
/** IntersectionObserver threshold for lazy visibility detection. @default 0.01 */
|
|
63
|
+
lazyThreshold?: number;
|
|
64
|
+
/** Fired when scrolling pauses (user interaction or Space key). */
|
|
65
|
+
onPause?: () => void;
|
|
66
|
+
/** Fired when scrolling resumes after pause. */
|
|
67
|
+
onResume?: () => void;
|
|
68
|
+
/** Fired on every position change with the current scroll position. */
|
|
69
|
+
onScroll?: (position: number) => void;
|
|
70
|
+
/** Pause auto-scroll during user interaction. @default true */
|
|
71
|
+
pauseOnInteraction?: boolean;
|
|
72
|
+
/** Milliseconds before auto-scroll resumes after interaction. @default 2000 */
|
|
73
|
+
resumeDelay?: number;
|
|
74
|
+
/** Auto-scroll speed in pixels per frame at 60fps. @default 0.5 */
|
|
75
|
+
speed?: number;
|
|
76
|
+
/** Enable mouse wheel scrolling. @default true */
|
|
77
|
+
wheelEnabled?: boolean;
|
|
11
78
|
}
|
|
12
79
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
80
|
+
/**
|
|
81
|
+
* A smooth, infinite scrolling container component.
|
|
82
|
+
*
|
|
83
|
+
* Renders children in a continuously looping scroll area with support for
|
|
84
|
+
* auto-scrolling, mouse drag, touch swipe, wheel, and keyboard interactions.
|
|
85
|
+
* Content is duplicated to create a seamless loop effect. Duplicate content
|
|
86
|
+
* is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
|
|
87
|
+
*
|
|
88
|
+
* Respects `prefers-reduced-motion: reduce` by lowering auto-scroll speed
|
|
89
|
+
* and disabling momentum effects.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```tsx
|
|
93
|
+
* <ReactSway direction="up" speed={1} friction={0.9}>
|
|
94
|
+
* <div className="content-item">Item 1</div>
|
|
95
|
+
* <div className="content-item">Item 2</div>
|
|
96
|
+
* </ReactSway>
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
function ReactSway({
|
|
100
|
+
autoScroll = true,
|
|
101
|
+
children,
|
|
102
|
+
direction = 'up',
|
|
103
|
+
draggable = true,
|
|
104
|
+
friction = DEFAULT_FRICTION,
|
|
105
|
+
keyboard = true,
|
|
106
|
+
lazy = true,
|
|
107
|
+
lazyRootMargin = DEFAULT_LAZY_ROOT_MARGIN,
|
|
108
|
+
lazyThreshold = DEFAULT_LAZY_THRESHOLD,
|
|
109
|
+
onPause,
|
|
110
|
+
onResume,
|
|
111
|
+
onScroll,
|
|
112
|
+
pauseOnInteraction = true,
|
|
113
|
+
resumeDelay = DEFAULT_RESUME_DELAY,
|
|
114
|
+
speed = DEFAULT_SPEED,
|
|
115
|
+
wheelEnabled = true,
|
|
116
|
+
}: ReactSwayProps) {
|
|
117
|
+
const normalizedFriction = useMemo(
|
|
118
|
+
() => (Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION),
|
|
119
|
+
[friction],
|
|
120
|
+
);
|
|
121
|
+
const normalizedResumeDelay = useMemo(
|
|
122
|
+
() => (Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY),
|
|
123
|
+
[resumeDelay],
|
|
124
|
+
);
|
|
125
|
+
const normalizedSpeed = useMemo(
|
|
126
|
+
() => (Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED),
|
|
127
|
+
[speed],
|
|
128
|
+
);
|
|
129
|
+
|
|
16
130
|
const [isDragging, setIsDragging] = useState(false);
|
|
17
131
|
const [isPaused, setIsPaused] = useState(false);
|
|
18
|
-
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
|
19
132
|
const [isTabActive, setIsTabActive] = useState(true);
|
|
20
|
-
|
|
21
|
-
// Content dimensions
|
|
22
|
-
const [contentHeight, setContentHeight] = useState(0);
|
|
23
133
|
const [loopPoint, setLoopPoint] = useState(0);
|
|
24
|
-
const [,
|
|
134
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
|
|
135
|
+
if (typeof window === 'undefined') return false;
|
|
136
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
137
|
+
});
|
|
25
138
|
|
|
26
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
27
139
|
const animationFrameRef = useRef<number | null>(null);
|
|
140
|
+
const autoScrollRef = useRef({ active: autoScroll, desired: autoScroll });
|
|
141
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
28
142
|
const inactivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
29
|
-
const
|
|
30
|
-
const
|
|
143
|
+
const isDraggingRef = useRef(false);
|
|
144
|
+
const isPausedRef = useRef(false);
|
|
31
145
|
const lastFrameTimeRef = useRef(0);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
146
|
+
const lastMouseYRef = useRef(0);
|
|
147
|
+
const lastTouchYRef = useRef(0);
|
|
148
|
+
const loopPointRef = useRef(0);
|
|
149
|
+
const onPauseRef = useRef(onPause);
|
|
150
|
+
const onResumeRef = useRef(onResume);
|
|
151
|
+
const onScrollRef = useRef(onScroll);
|
|
152
|
+
const positionRef = useRef(0);
|
|
153
|
+
const resizeDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
154
|
+
const velocityRef = useRef(0);
|
|
155
|
+
|
|
156
|
+
// Keep callback refs in sync without triggering re-renders
|
|
37
157
|
useEffect(() => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
containerRef.current.offsetHeight;
|
|
43
|
-
|
|
44
|
-
const currentContentHeight = containerRef.current.scrollHeight;
|
|
45
|
-
const calculatedLoopPoint = currentContentHeight / 3;
|
|
46
|
-
|
|
47
|
-
console.log('Calculating dimensions:', { currentContentHeight, calculatedLoopPoint });
|
|
158
|
+
onPauseRef.current = onPause;
|
|
159
|
+
onResumeRef.current = onResume;
|
|
160
|
+
onScrollRef.current = onScroll;
|
|
161
|
+
}, [onPause, onResume, onScroll]);
|
|
48
162
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
163
|
+
// Listen for prefers-reduced-motion media query changes
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (typeof window === 'undefined') return;
|
|
166
|
+
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
167
|
+
const handleChange = (e: MediaQueryListEvent) => {
|
|
168
|
+
setPrefersReducedMotion(e.matches);
|
|
56
169
|
};
|
|
57
|
-
|
|
58
|
-
// Use RAF to ensure layout is complete
|
|
59
|
-
const rafId = requestAnimationFrame(() => {
|
|
60
|
-
calculateDimensions();
|
|
61
|
-
});
|
|
62
|
-
|
|
170
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
63
171
|
return () => {
|
|
64
|
-
|
|
172
|
+
mediaQuery.removeEventListener('change', handleChange);
|
|
65
173
|
};
|
|
66
|
-
}, [
|
|
174
|
+
}, []);
|
|
67
175
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
176
|
+
const clearInactivityTimer = useCallback(() => {
|
|
177
|
+
if (!inactivityTimerRef.current) return;
|
|
178
|
+
clearTimeout(inactivityTimerRef.current);
|
|
179
|
+
inactivityTimerRef.current = null;
|
|
73
180
|
}, []);
|
|
74
181
|
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
182
|
+
const renderPosition = useCallback((rawPosition: number) => {
|
|
183
|
+
if (!containerRef.current) return;
|
|
184
|
+
const currentLoopPoint = loopPointRef.current;
|
|
185
|
+
let visualPosition = rawPosition % (currentLoopPoint || 1);
|
|
186
|
+
if (visualPosition > 0 && currentLoopPoint > 0) {
|
|
187
|
+
visualPosition -= currentLoopPoint;
|
|
78
188
|
}
|
|
79
|
-
|
|
80
|
-
setAutoScrollEnabled(true);
|
|
81
|
-
}, INACTIVITY_DELAY);
|
|
189
|
+
containerRef.current.style.transform = `translate3d(0, ${visualPosition}px, 0)`;
|
|
82
190
|
}, []);
|
|
83
191
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}, [pauseAutoScroll]);
|
|
192
|
+
const commitPosition = useCallback((nextPosition: number) => {
|
|
193
|
+
if (positionRef.current === nextPosition) return;
|
|
194
|
+
positionRef.current = nextPosition;
|
|
195
|
+
renderPosition(nextPosition);
|
|
196
|
+
onScrollRef.current?.(nextPosition);
|
|
197
|
+
}, [renderPosition]);
|
|
91
198
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const deltaY = e.clientY - lastMouseYRef.current;
|
|
96
|
-
setPosition(prev => prev + deltaY);
|
|
97
|
-
setVelocity(deltaY);
|
|
98
|
-
lastMouseYRef.current = e.clientY;
|
|
99
|
-
}, [isDragging]);
|
|
199
|
+
const wrapPosition = useCallback((rawPosition: number) => {
|
|
200
|
+
const currentLoopPoint = loopPointRef.current;
|
|
201
|
+
if (currentLoopPoint <= 0) return rawPosition;
|
|
100
202
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
203
|
+
let wrappedPosition = rawPosition;
|
|
204
|
+
while (wrappedPosition > 0) {
|
|
205
|
+
wrappedPosition -= currentLoopPoint;
|
|
206
|
+
}
|
|
207
|
+
while (wrappedPosition < -currentLoopPoint * 2) {
|
|
208
|
+
wrappedPosition += currentLoopPoint;
|
|
209
|
+
}
|
|
210
|
+
return wrappedPosition;
|
|
211
|
+
}, []);
|
|
107
212
|
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
213
|
+
const recalculateLoopPoint = useCallback(() => {
|
|
214
|
+
if (!containerRef.current) return;
|
|
215
|
+
const currentContentHeight = containerRef.current.scrollHeight;
|
|
216
|
+
if (currentContentHeight <= 0) return;
|
|
217
|
+
|
|
218
|
+
const nextLoopPoint = currentContentHeight / LOOP_SEGMENTS;
|
|
219
|
+
loopPointRef.current = nextLoopPoint;
|
|
220
|
+
setLoopPoint((previousLoopPoint) => (Math.abs(previousLoopPoint - nextLoopPoint) < 0.5 ? previousLoopPoint : nextLoopPoint));
|
|
221
|
+
renderPosition(positionRef.current);
|
|
222
|
+
}, [renderPosition]);
|
|
223
|
+
|
|
224
|
+
// Sync autoScroll prop changes with internal state
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
autoScrollRef.current.desired = autoScroll;
|
|
227
|
+
if (!autoScroll) {
|
|
228
|
+
clearInactivityTimer();
|
|
229
|
+
autoScrollRef.current.active = true;
|
|
114
230
|
}
|
|
115
|
-
}, [
|
|
231
|
+
}, [autoScroll, clearInactivityTimer]);
|
|
116
232
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
setPosition(prev => prev + deltaY);
|
|
123
|
-
setVelocity(deltaY);
|
|
124
|
-
lastTouchYRef.current = touch.clientY;
|
|
125
|
-
}, [isDragging]);
|
|
233
|
+
// Sync loopPoint ref after state updates from observers/resizes
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
loopPointRef.current = loopPoint;
|
|
236
|
+
renderPosition(positionRef.current);
|
|
237
|
+
}, [loopPoint, renderPosition]);
|
|
126
238
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}, [isDragging, scheduleAutoScrollResume]);
|
|
239
|
+
// Dimension calculation
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
// Use RAF to ensure layout is complete
|
|
242
|
+
const rafId = requestAnimationFrame(recalculateLoopPoint);
|
|
132
243
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
244
|
+
return () => {
|
|
245
|
+
cancelAnimationFrame(rafId);
|
|
246
|
+
};
|
|
247
|
+
}, [children, recalculateLoopPoint]);
|
|
248
|
+
|
|
249
|
+
const pauseAutoScroll = useCallback(() => {
|
|
250
|
+
if (!pauseOnInteraction) return;
|
|
251
|
+
autoScrollRef.current.active = false;
|
|
252
|
+
onPauseRef.current?.();
|
|
253
|
+
clearInactivityTimer();
|
|
254
|
+
}, [clearInactivityTimer, pauseOnInteraction]);
|
|
255
|
+
|
|
256
|
+
const scheduleAutoScrollResume = useCallback(() => {
|
|
257
|
+
if (!pauseOnInteraction || !autoScrollRef.current.desired || isPausedRef.current) return;
|
|
258
|
+
clearInactivityTimer();
|
|
259
|
+
|
|
260
|
+
inactivityTimerRef.current = setTimeout(() => {
|
|
261
|
+
inactivityTimerRef.current = null;
|
|
262
|
+
if (!autoScrollRef.current.desired || isPausedRef.current) return;
|
|
263
|
+
autoScrollRef.current.active = true;
|
|
264
|
+
onResumeRef.current?.();
|
|
265
|
+
}, normalizedResumeDelay);
|
|
266
|
+
}, [clearInactivityTimer, normalizedResumeDelay, pauseOnInteraction]);
|
|
139
267
|
|
|
140
268
|
const togglePause = useCallback(() => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
269
|
+
const newPaused = !isPausedRef.current;
|
|
270
|
+
isPausedRef.current = newPaused;
|
|
271
|
+
setIsPaused(newPaused);
|
|
272
|
+
if (newPaused) {
|
|
273
|
+
clearInactivityTimer();
|
|
274
|
+
autoScrollRef.current.active = false;
|
|
275
|
+
onPauseRef.current?.();
|
|
276
|
+
} else {
|
|
277
|
+
clearInactivityTimer();
|
|
278
|
+
autoScrollRef.current.active = true;
|
|
279
|
+
if (autoScrollRef.current.desired) {
|
|
280
|
+
onResumeRef.current?.();
|
|
147
281
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
282
|
+
}
|
|
283
|
+
}, [clearInactivityTimer]);
|
|
284
|
+
|
|
285
|
+
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
286
|
+
if (!keyboard) return;
|
|
151
287
|
|
|
152
|
-
const handleKeyDown = useCallback((e: globalThis.KeyboardEvent) => {
|
|
153
288
|
switch (e.key) {
|
|
154
289
|
case ' ':
|
|
155
290
|
e.preventDefault();
|
|
156
291
|
togglePause();
|
|
157
292
|
break;
|
|
158
|
-
case '
|
|
293
|
+
case 'ArrowDown':
|
|
159
294
|
e.preventDefault();
|
|
160
|
-
|
|
295
|
+
velocityRef.current -= ARROW_KEY_VELOCITY;
|
|
161
296
|
pauseAutoScroll();
|
|
162
297
|
scheduleAutoScrollResume();
|
|
163
298
|
break;
|
|
164
|
-
case '
|
|
299
|
+
case 'ArrowUp':
|
|
165
300
|
e.preventDefault();
|
|
166
|
-
|
|
301
|
+
velocityRef.current += ARROW_KEY_VELOCITY;
|
|
167
302
|
pauseAutoScroll();
|
|
168
303
|
scheduleAutoScrollResume();
|
|
169
304
|
break;
|
|
170
|
-
case '
|
|
305
|
+
case 'End':
|
|
171
306
|
e.preventDefault();
|
|
172
|
-
|
|
173
|
-
|
|
307
|
+
if (loopPointRef.current > 0) {
|
|
308
|
+
commitPosition(-loopPointRef.current);
|
|
309
|
+
}
|
|
310
|
+
velocityRef.current = 0;
|
|
174
311
|
pauseAutoScroll();
|
|
175
312
|
scheduleAutoScrollResume();
|
|
176
313
|
break;
|
|
177
|
-
case '
|
|
314
|
+
case 'Home':
|
|
178
315
|
e.preventDefault();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
setVelocity(0);
|
|
316
|
+
commitPosition(0);
|
|
317
|
+
velocityRef.current = 0;
|
|
183
318
|
pauseAutoScroll();
|
|
184
319
|
scheduleAutoScrollResume();
|
|
185
320
|
break;
|
|
186
321
|
default:
|
|
187
322
|
break;
|
|
188
323
|
}
|
|
189
|
-
}, [
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
324
|
+
}, [commitPosition, keyboard, pauseAutoScroll, scheduleAutoScrollResume, togglePause]);
|
|
325
|
+
|
|
326
|
+
const handleMouseDown = useCallback((e: globalThis.MouseEvent) => {
|
|
327
|
+
if (!draggable) return;
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
containerRef.current?.focus();
|
|
330
|
+
setIsDragging(true);
|
|
331
|
+
isDraggingRef.current = true;
|
|
332
|
+
lastMouseYRef.current = e.clientY;
|
|
333
|
+
velocityRef.current = 0;
|
|
334
|
+
pauseAutoScroll();
|
|
335
|
+
}, [draggable, pauseAutoScroll]);
|
|
336
|
+
|
|
337
|
+
const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
|
|
338
|
+
if (!isDraggingRef.current) return;
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
const deltaY = e.clientY - lastMouseYRef.current;
|
|
341
|
+
const nextPosition = wrapPosition(positionRef.current + deltaY);
|
|
342
|
+
commitPosition(nextPosition);
|
|
343
|
+
velocityRef.current = deltaY;
|
|
344
|
+
lastMouseYRef.current = e.clientY;
|
|
345
|
+
}, [commitPosition, wrapPosition]);
|
|
346
|
+
|
|
347
|
+
const handleMouseUp = useCallback((e: globalThis.MouseEvent) => {
|
|
348
|
+
if (!isDraggingRef.current) return;
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
setIsDragging(false);
|
|
351
|
+
isDraggingRef.current = false;
|
|
352
|
+
scheduleAutoScrollResume();
|
|
353
|
+
}, [scheduleAutoScrollResume]);
|
|
354
|
+
|
|
355
|
+
const handleTouchEnd = useCallback((_e: globalThis.TouchEvent) => {
|
|
356
|
+
if (!isDraggingRef.current) return;
|
|
357
|
+
setIsDragging(false);
|
|
358
|
+
isDraggingRef.current = false;
|
|
359
|
+
scheduleAutoScrollResume();
|
|
360
|
+
}, [scheduleAutoScrollResume]);
|
|
361
|
+
|
|
362
|
+
const handleTouchMove = useCallback((e: globalThis.TouchEvent) => {
|
|
363
|
+
if (!isDraggingRef.current || e.touches.length !== 1) return;
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
const touch = e.touches[0];
|
|
366
|
+
const deltaY = touch.clientY - lastTouchYRef.current;
|
|
367
|
+
const nextPosition = wrapPosition(positionRef.current + deltaY);
|
|
368
|
+
commitPosition(nextPosition);
|
|
369
|
+
velocityRef.current = deltaY;
|
|
370
|
+
lastTouchYRef.current = touch.clientY;
|
|
371
|
+
}, [commitPosition, wrapPosition]);
|
|
372
|
+
|
|
373
|
+
const handleTouchStart = useCallback((e: globalThis.TouchEvent) => {
|
|
374
|
+
if (!draggable || e.touches.length !== 1) return;
|
|
375
|
+
containerRef.current?.focus();
|
|
376
|
+
setIsDragging(true);
|
|
377
|
+
isDraggingRef.current = true;
|
|
378
|
+
lastTouchYRef.current = e.touches[0].clientY;
|
|
379
|
+
velocityRef.current = 0;
|
|
380
|
+
pauseAutoScroll();
|
|
381
|
+
}, [draggable, pauseAutoScroll]);
|
|
199
382
|
|
|
383
|
+
const handleWheel = useCallback((e: globalThis.WheelEvent) => {
|
|
384
|
+
if (!wheelEnabled) return;
|
|
385
|
+
e.preventDefault();
|
|
386
|
+
velocityRef.current -= e.deltaY * WHEEL_VELOCITY_MULTIPLIER;
|
|
387
|
+
velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
|
|
388
|
+
pauseAutoScroll();
|
|
389
|
+
scheduleAutoScrollResume();
|
|
390
|
+
}, [pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled]);
|
|
391
|
+
|
|
392
|
+
// Event listener registration
|
|
200
393
|
useEffect(() => {
|
|
201
394
|
const currentContainer = containerRef.current;
|
|
202
395
|
if (!currentContainer) return;
|
|
203
396
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
397
|
+
currentContainer.addEventListener('mousedown', handleMouseDown);
|
|
398
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
399
|
+
window.addEventListener('mouseup', handleMouseUp);
|
|
400
|
+
currentContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
401
|
+
window.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
402
|
+
window.addEventListener('touchend', handleTouchEnd, { passive: true });
|
|
403
|
+
currentContainer.addEventListener('wheel', handleWheel, { passive: false });
|
|
404
|
+
|
|
405
|
+
return () => {
|
|
406
|
+
currentContainer.removeEventListener('mousedown', handleMouseDown);
|
|
407
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
408
|
+
window.removeEventListener('mouseup', handleMouseUp);
|
|
409
|
+
currentContainer.removeEventListener('touchstart', handleTouchStart);
|
|
410
|
+
window.removeEventListener('touchmove', handleTouchMove);
|
|
411
|
+
window.removeEventListener('touchend', handleTouchEnd);
|
|
412
|
+
currentContainer.removeEventListener('wheel', handleWheel);
|
|
213
413
|
};
|
|
414
|
+
}, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchEnd, handleTouchMove, handleTouchStart, handleWheel]);
|
|
415
|
+
|
|
416
|
+
// Debounced resize handler shared by ResizeObserver and window resize fallback
|
|
417
|
+
const debouncedRecalculate = useCallback(() => {
|
|
418
|
+
if (resizeDebounceTimerRef.current) {
|
|
419
|
+
clearTimeout(resizeDebounceTimerRef.current);
|
|
420
|
+
}
|
|
421
|
+
resizeDebounceTimerRef.current = setTimeout(() => {
|
|
422
|
+
resizeDebounceTimerRef.current = null;
|
|
423
|
+
recalculateLoopPoint();
|
|
424
|
+
}, RESIZE_DEBOUNCE_MS);
|
|
425
|
+
}, [recalculateLoopPoint]);
|
|
214
426
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
window.addEventListener('
|
|
218
|
-
window.addEventListener('mouseup', boundHandlers.mouseUp);
|
|
219
|
-
currentContainer.addEventListener('touchstart', boundHandlers.touchStart, { passive: true });
|
|
220
|
-
window.addEventListener('touchmove', boundHandlers.touchMove, { passive: false });
|
|
221
|
-
window.addEventListener('touchend', boundHandlers.touchEnd, { passive: true });
|
|
222
|
-
currentContainer.addEventListener('wheel', boundHandlers.wheel, { passive: false });
|
|
427
|
+
// Resize listener fallback for browsers without ResizeObserver
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
window.addEventListener('resize', debouncedRecalculate);
|
|
223
430
|
|
|
224
431
|
return () => {
|
|
225
|
-
|
|
226
|
-
window.removeEventListener('mousemove', boundHandlers.mouseMove);
|
|
227
|
-
window.removeEventListener('mouseup', boundHandlers.mouseUp);
|
|
228
|
-
currentContainer.removeEventListener('touchstart', boundHandlers.touchStart);
|
|
229
|
-
window.removeEventListener('touchmove', boundHandlers.touchMove);
|
|
230
|
-
window.removeEventListener('touchend', boundHandlers.touchEnd);
|
|
231
|
-
currentContainer.removeEventListener('wheel', boundHandlers.wheel);
|
|
432
|
+
window.removeEventListener('resize', debouncedRecalculate);
|
|
232
433
|
};
|
|
233
|
-
}, [
|
|
434
|
+
}, [debouncedRecalculate]);
|
|
234
435
|
|
|
436
|
+
// ResizeObserver keeps loop measurements in sync with async content changes
|
|
235
437
|
useEffect(() => {
|
|
236
|
-
|
|
237
|
-
|
|
438
|
+
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
|
|
439
|
+
|
|
440
|
+
const observer = new ResizeObserver(() => {
|
|
441
|
+
debouncedRecalculate();
|
|
442
|
+
});
|
|
443
|
+
observer.observe(containerRef.current);
|
|
238
444
|
|
|
239
445
|
return () => {
|
|
240
|
-
|
|
241
|
-
|
|
446
|
+
observer.disconnect();
|
|
447
|
+
};
|
|
448
|
+
}, [debouncedRecalculate]);
|
|
449
|
+
|
|
450
|
+
// Clean up resize debounce timer on unmount
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
return () => {
|
|
453
|
+
if (resizeDebounceTimerRef.current) {
|
|
454
|
+
clearTimeout(resizeDebounceTimerRef.current);
|
|
455
|
+
resizeDebounceTimerRef.current = null;
|
|
456
|
+
}
|
|
242
457
|
};
|
|
243
|
-
}, [
|
|
458
|
+
}, []);
|
|
244
459
|
|
|
245
|
-
// Tab
|
|
460
|
+
// Tab visibility handling
|
|
246
461
|
useEffect(() => {
|
|
247
462
|
const handleVisibilityChange = () => {
|
|
248
463
|
setIsTabActive(!document.hidden);
|
|
@@ -257,7 +472,14 @@ function ReactSway({ children }: ReactSwayProps) {
|
|
|
257
472
|
};
|
|
258
473
|
}, []);
|
|
259
474
|
|
|
260
|
-
//
|
|
475
|
+
// Clean up inactivity timer on unmount
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
return () => {
|
|
478
|
+
clearInactivityTimer();
|
|
479
|
+
};
|
|
480
|
+
}, [clearInactivityTimer]);
|
|
481
|
+
|
|
482
|
+
// Animation loop
|
|
261
483
|
useEffect(() => {
|
|
262
484
|
if (!isTabActive || isPaused) {
|
|
263
485
|
if (animationFrameRef.current) {
|
|
@@ -267,52 +489,46 @@ function ReactSway({ children }: ReactSwayProps) {
|
|
|
267
489
|
return;
|
|
268
490
|
}
|
|
269
491
|
|
|
492
|
+
// Reset so the first frame uses deltaTime=1 instead of a stale timestamp
|
|
493
|
+
lastFrameTimeRef.current = 0;
|
|
494
|
+
|
|
495
|
+
const directionMultiplier = direction === 'down' ? 1 : -1;
|
|
496
|
+
|
|
270
497
|
const animate = (currentTime: number) => {
|
|
271
|
-
let deltaTime = lastFrameTimeRef.current
|
|
498
|
+
let deltaTime = lastFrameTimeRef.current
|
|
499
|
+
? (currentTime - lastFrameTimeRef.current) / MS_PER_FRAME_60FPS
|
|
500
|
+
: 1;
|
|
272
501
|
deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
|
|
273
502
|
lastFrameTimeRef.current = currentTime;
|
|
274
503
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return newPosition;
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
setVelocity(prevVelocity => {
|
|
287
|
-
let newVelocity = prevVelocity;
|
|
504
|
+
// Apply velocity damping (skip momentum in reduced-motion mode)
|
|
505
|
+
if (prefersReducedMotion) {
|
|
506
|
+
velocityRef.current = 0;
|
|
507
|
+
} else if (Math.abs(velocityRef.current) > 0.1) {
|
|
508
|
+
velocityRef.current *= Math.pow(normalizedFriction, deltaTime);
|
|
509
|
+
} else {
|
|
510
|
+
velocityRef.current = 0;
|
|
511
|
+
}
|
|
288
512
|
|
|
289
|
-
|
|
290
|
-
if (!isDragging) {
|
|
291
|
-
setPosition(prev => prev + newVelocity * deltaTime);
|
|
292
|
-
}
|
|
293
|
-
newVelocity *= Math.pow(FRICTION, deltaTime);
|
|
294
|
-
} else {
|
|
295
|
-
newVelocity = 0;
|
|
296
|
-
}
|
|
513
|
+
let nextPosition = positionRef.current;
|
|
297
514
|
|
|
298
|
-
|
|
299
|
-
|
|
515
|
+
// Calculate effective speed (reduced when user prefers reduced motion)
|
|
516
|
+
const effectiveSpeed = prefersReducedMotion
|
|
517
|
+
? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR
|
|
518
|
+
: normalizedSpeed;
|
|
300
519
|
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
520
|
+
// Auto-scroll when enabled and not dragging
|
|
521
|
+
if (autoScrollRef.current.desired && autoScrollRef.current.active && !isDraggingRef.current) {
|
|
522
|
+
nextPosition += directionMultiplier * effectiveSpeed * deltaTime;
|
|
523
|
+
}
|
|
304
524
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
while (newPosition < -loopPoint * 2) {
|
|
310
|
-
newPosition += loopPoint;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
525
|
+
// Apply velocity momentum from user interaction
|
|
526
|
+
if (!isDraggingRef.current && Math.abs(velocityRef.current) > 0.1) {
|
|
527
|
+
nextPosition += velocityRef.current * deltaTime;
|
|
528
|
+
}
|
|
313
529
|
|
|
314
|
-
|
|
315
|
-
|
|
530
|
+
nextPosition = wrapPosition(nextPosition);
|
|
531
|
+
commitPosition(nextPosition);
|
|
316
532
|
|
|
317
533
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
318
534
|
};
|
|
@@ -324,11 +540,11 @@ function ReactSway({ children }: ReactSwayProps) {
|
|
|
324
540
|
cancelAnimationFrame(animationFrameRef.current);
|
|
325
541
|
}
|
|
326
542
|
};
|
|
327
|
-
}, [isTabActive,
|
|
543
|
+
}, [commitPosition, direction, isPaused, isTabActive, normalizedFriction, normalizedSpeed, prefersReducedMotion, wrapPosition]);
|
|
328
544
|
|
|
329
|
-
// Intersection Observer for lazy
|
|
545
|
+
// Intersection Observer for lazy visibility detection
|
|
330
546
|
useEffect(() => {
|
|
331
|
-
if (!containerRef.current) return;
|
|
547
|
+
if (!lazy || !containerRef.current || typeof IntersectionObserver === 'undefined') return;
|
|
332
548
|
|
|
333
549
|
const observer = new IntersectionObserver(
|
|
334
550
|
(entries) => {
|
|
@@ -340,8 +556,8 @@ function ReactSway({ children }: ReactSwayProps) {
|
|
|
340
556
|
},
|
|
341
557
|
{
|
|
342
558
|
root: null,
|
|
343
|
-
rootMargin:
|
|
344
|
-
threshold:
|
|
559
|
+
rootMargin: lazyRootMargin,
|
|
560
|
+
threshold: lazyThreshold,
|
|
345
561
|
}
|
|
346
562
|
);
|
|
347
563
|
|
|
@@ -352,60 +568,42 @@ function ReactSway({ children }: ReactSwayProps) {
|
|
|
352
568
|
items.forEach((item) => observer.unobserve(item));
|
|
353
569
|
observer.disconnect();
|
|
354
570
|
};
|
|
355
|
-
}, [children,
|
|
356
|
-
|
|
357
|
-
// Apply styles to override conflicting CSS
|
|
358
|
-
useEffect(() => {
|
|
359
|
-
const originalBodyStyle = {
|
|
360
|
-
touchAction: document.body.style.touchAction,
|
|
361
|
-
overflow: document.body.style.overflow
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
// Override body styles that might conflict
|
|
365
|
-
document.body.style.touchAction = 'none';
|
|
366
|
-
document.body.style.overflow = 'hidden';
|
|
367
|
-
|
|
368
|
-
return () => {
|
|
369
|
-
// Restore original styles
|
|
370
|
-
document.body.style.touchAction = originalBodyStyle.touchAction;
|
|
371
|
-
document.body.style.overflow = originalBodyStyle.overflow;
|
|
372
|
-
};
|
|
373
|
-
}, []);
|
|
571
|
+
}, [children, lazy, lazyRootMargin, lazyThreshold]);
|
|
374
572
|
|
|
375
573
|
return (
|
|
376
574
|
<div
|
|
377
575
|
className="react-sway-container scroller-content"
|
|
378
576
|
ref={containerRef}
|
|
379
577
|
style={{
|
|
380
|
-
|
|
381
|
-
|
|
578
|
+
cursor: draggable ? (isDragging ? 'grabbing' : 'grab') : 'default',
|
|
579
|
+
MozUserSelect: 'none',
|
|
580
|
+
msUserSelect: 'none',
|
|
581
|
+
overflow: 'hidden',
|
|
582
|
+
overscrollBehavior: 'contain',
|
|
583
|
+
pointerEvents: 'auto',
|
|
382
584
|
position: 'absolute',
|
|
383
|
-
width: '100%',
|
|
384
|
-
willChange: 'transform',
|
|
385
|
-
WebkitTransform: 'translateZ(0)',
|
|
386
585
|
touchAction: 'none',
|
|
586
|
+
transform: 'translate3d(0, 0px, 0)',
|
|
387
587
|
userSelect: 'none',
|
|
388
588
|
WebkitUserSelect: 'none',
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
// Ensure it's on top and can receive events
|
|
393
|
-
pointerEvents: 'auto',
|
|
394
|
-
zIndex: 1
|
|
589
|
+
width: '100%',
|
|
590
|
+
willChange: 'transform',
|
|
591
|
+
zIndex: 1,
|
|
395
592
|
}}
|
|
396
|
-
|
|
593
|
+
onKeyDown={keyboard ? handleKeyDown : undefined}
|
|
594
|
+
tabIndex={keyboard ? 0 : undefined}
|
|
397
595
|
>
|
|
398
596
|
<div className="content-group original">
|
|
399
597
|
{children}
|
|
400
598
|
</div>
|
|
401
|
-
<aside className="content-group duplicate"
|
|
599
|
+
<aside aria-hidden="true" className="content-group duplicate" data-duplicate="true" role="presentation">
|
|
402
600
|
{children}
|
|
403
601
|
</aside>
|
|
404
|
-
<aside className="content-group duplicate"
|
|
602
|
+
<aside aria-hidden="true" className="content-group duplicate" data-duplicate="true" role="presentation">
|
|
405
603
|
{children}
|
|
406
604
|
</aside>
|
|
407
605
|
</div>
|
|
408
606
|
);
|
|
409
607
|
}
|
|
410
608
|
|
|
411
|
-
export default ReactSway;
|
|
609
|
+
export default ReactSway;
|