react-sway 0.3.0 → 0.4.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/src/ReactSway.tsx DELETED
@@ -1,914 +0,0 @@
1
- /**
2
- * Core ReactSway component implementing axis-aware infinite scrolling interactions.
3
- */
4
- import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
-
6
- /** Velocity applied per arrow key press (pixels). */
7
- const ARROW_KEY_VELOCITY = 15;
8
-
9
- /** Default edge-hover activation thickness in pixels. */
10
- const DEFAULT_EDGE_HOVER_SIZE = 96;
11
-
12
- /** Default friction coefficient applied to velocity each frame. */
13
- const DEFAULT_FRICTION = 0.95;
14
-
15
- /** Default IntersectionObserver rootMargin for lazy visibility detection. */
16
- const DEFAULT_LAZY_ROOT_MARGIN = '100px';
17
-
18
- /** Default IntersectionObserver threshold for lazy visibility detection. */
19
- const DEFAULT_LAZY_THRESHOLD = 0.01;
20
-
21
- /** Default delay in milliseconds before auto-scroll resumes after user interaction. */
22
- const DEFAULT_RESUME_DELAY = 2000;
23
-
24
- /** Default scroll speed in pixels per frame at 60fps. */
25
- const DEFAULT_SPEED = 0.5;
26
-
27
- /** Number of stacked content groups used to build the seamless loop. */
28
- const MIN_LOOP_SEGMENTS = 3;
29
-
30
- /** Maximum deltaTime cap to prevent physics instability during frame drops. */
31
- const MAX_DELTA_TIME = 3;
32
-
33
- /** Maximum allowed velocity magnitude to prevent runaway scrolling. */
34
- const MAX_VELOCITY = 150;
35
-
36
- /** Duration of a single frame at 60fps in milliseconds. */
37
- const MS_PER_FRAME_60FPS = 16.667;
38
-
39
- /** Speed multiplier when user prefers reduced motion (25% of normal). */
40
- const REDUCED_MOTION_SPEED_FACTOR = 0.25;
41
-
42
- /** Debounce delay in milliseconds for ResizeObserver callbacks. */
43
- const RESIZE_DEBOUNCE_MS = 150;
44
-
45
- /** Fallback pixel height for wheel events reported in line units. */
46
- const WHEEL_LINE_HEIGHT_FALLBACK_PX = 16;
47
-
48
- /** WheelEvent deltaMode value for line-based deltas. */
49
- const WHEEL_MODE_LINE = 1;
50
-
51
- /** WheelEvent deltaMode value for page-based deltas. */
52
- const WHEEL_MODE_PAGE = 2;
53
-
54
- /** Minimum pixel impulse for discrete wheel hardware reporting tiny pixel deltas. */
55
- const WHEEL_PIXEL_MIN_DELTA_PX = 48;
56
-
57
- /** Multiplier applied to wheel deltaY to convert to scroll velocity. */
58
- const WHEEL_VELOCITY_MULTIPLIER = 0.14;
59
-
60
- /** Small immediate wheel movement used to keep input responsive without jumping. */
61
- const WHEEL_IMMEDIATE_DELTA_FACTOR = 0.14;
62
-
63
- /** Legacy wheelDelta magnitude that usually represents one physical wheel notch. */
64
- const WHEEL_LEGACY_NOTCH_DELTA = 120;
65
-
66
- /** Minimum touch movement before a gesture is classified by axis. */
67
- const TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
68
-
69
- interface WheelEventWithLegacyDelta extends globalThis.WheelEvent {
70
- wheelDelta?: number;
71
- wheelDeltaY?: number;
72
- }
73
-
74
- export type SwayAxis = 'horizontal' | 'vertical';
75
- export type SwayDirection = 'down' | 'left' | 'right' | 'up';
76
- export type SwayWheelMode = 'axis' | 'capture';
77
-
78
- type WheelDeltaAxis = 'x' | 'y';
79
- type TouchInteractionState = 'active' | 'idle' | 'ignored' | 'pending';
80
-
81
- function getLegacyWheelPixelDeltaY(event: globalThis.WheelEvent) {
82
- const { wheelDelta, wheelDeltaY } = event as WheelEventWithLegacyDelta;
83
- const legacyWheelDelta = typeof wheelDeltaY === 'number' ? wheelDeltaY : wheelDelta;
84
-
85
- if (typeof legacyWheelDelta !== 'number' || !Number.isFinite(legacyWheelDelta)) {
86
- return 0;
87
- }
88
-
89
- return -(legacyWheelDelta / WHEEL_LEGACY_NOTCH_DELTA) * WHEEL_PIXEL_MIN_DELTA_PX;
90
- }
91
-
92
- /**
93
- * Converts wheel deltas to pixels so mouse wheels, touchpads, and page-wheel
94
- * devices feed the same Sway velocity system.
95
- */
96
- function normalizeWheelDelta(event: globalThis.WheelEvent, container: HTMLElement, axis: WheelDeltaAxis) {
97
- const rawDelta = axis === 'x' ? event.deltaX : event.deltaY;
98
-
99
- if (event.deltaMode === WHEEL_MODE_LINE) {
100
- const computedLineHeight = Number.parseFloat(window.getComputedStyle(container).lineHeight);
101
- const lineHeight = Number.isFinite(computedLineHeight)
102
- ? computedLineHeight
103
- : WHEEL_LINE_HEIGHT_FALLBACK_PX;
104
-
105
- return rawDelta * lineHeight;
106
- }
107
-
108
- if (event.deltaMode === WHEEL_MODE_PAGE) {
109
- const pageSize = axis === 'x'
110
- ? Math.max(container.clientWidth, window.innerWidth, 1)
111
- : Math.max(container.clientHeight, window.innerHeight, 1);
112
-
113
- return rawDelta * pageSize;
114
- }
115
-
116
- if (axis === 'x') return rawDelta;
117
-
118
- const legacyWheelPixelDeltaY = getLegacyWheelPixelDeltaY(event);
119
-
120
- if (
121
- Math.abs(rawDelta) < WHEEL_PIXEL_MIN_DELTA_PX &&
122
- Math.abs(legacyWheelPixelDeltaY) >= WHEEL_PIXEL_MIN_DELTA_PX
123
- ) {
124
- return legacyWheelPixelDeltaY;
125
- }
126
-
127
- return rawDelta;
128
- }
129
-
130
- function getOwnedWheelDelta(
131
- event: globalThis.WheelEvent,
132
- container: HTMLElement,
133
- isHorizontal: boolean,
134
- wheelMode: SwayWheelMode,
135
- ) {
136
- // The ownership function is intentionally partial: null means native scroll
137
- // chaining remains the browser's responsibility for cross-axis gestures.
138
- const deltaX = normalizeWheelDelta(event, container, 'x');
139
- const deltaY = normalizeWheelDelta(event, container, 'y');
140
- const absDeltaX = Math.abs(deltaX);
141
- const absDeltaY = Math.abs(deltaY);
142
-
143
- if (isHorizontal) {
144
- if (wheelMode === 'axis' && absDeltaX <= absDeltaY && !event.shiftKey) return null;
145
- return absDeltaX > absDeltaY ? deltaX : deltaY;
146
- }
147
-
148
- if (wheelMode === 'axis' && absDeltaX > absDeltaY) return null;
149
- return deltaY;
150
- }
151
-
152
- function getVisibleEdgeInterval(rect: DOMRect, isHorizontal: boolean) {
153
- const rectEnd = isHorizontal ? rect.right : rect.bottom;
154
- const rectStart = isHorizontal ? rect.left : rect.top;
155
- const viewportEnd = isHorizontal ? window.innerWidth : window.innerHeight;
156
-
157
- const end = Math.min(rectEnd, viewportEnd);
158
- const start = Math.max(rectStart, 0);
159
-
160
- if (end <= start) return null;
161
-
162
- return {
163
- end,
164
- size: end - start,
165
- start,
166
- };
167
- }
168
-
169
- /**
170
- * Props for the ReactSway infinite scrolling component.
171
- */
172
- export interface ReactSwayProps {
173
- /** Scroll axis. Horizontal directions imply `horizontal` when omitted. @default 'vertical' */
174
- axis?: SwayAxis;
175
- /** Enable/disable auto-scrolling. @default true */
176
- autoScroll?: boolean;
177
- /** Content elements to render in the infinite scroll container. */
178
- children: ReactNode;
179
- /** Auto-scroll direction. @default 'up' */
180
- direction?: SwayDirection;
181
- /** Enable mouse/touch drag interaction. @default true */
182
- draggable?: boolean;
183
- /** Only auto-scroll while the pointer hovers an axis boundary. @default false */
184
- edgeHoverScroll?: boolean;
185
- /** Edge-hover activation thickness in pixels. @default 96 */
186
- edgeHoverSize?: number;
187
- /** Momentum decay coefficient (0-1, lower = more friction). @default 0.95 */
188
- friction?: number;
189
- /** Enable keyboard controls (Space, Arrow keys, Home/End). @default true */
190
- keyboard?: boolean;
191
- /** Enable lazy visibility detection via IntersectionObserver. @default true */
192
- lazy?: boolean;
193
- /** IntersectionObserver rootMargin for lazy visibility detection. @default '100px' */
194
- lazyRootMargin?: string;
195
- /** IntersectionObserver threshold for lazy visibility detection. @default 0.01 */
196
- lazyThreshold?: number;
197
- /** Fired when scrolling pauses (user interaction or Space key). */
198
- onPause?: () => void;
199
- /** Fired when scrolling resumes after pause. */
200
- onResume?: () => void;
201
- /** Fired on every position change with the current scroll position. */
202
- onScroll?: (position: number) => void;
203
- /** Pause auto-scroll during user interaction. @default true */
204
- pauseOnInteraction?: boolean;
205
- /** Milliseconds before auto-scroll resumes after interaction. @default 2000 */
206
- resumeDelay?: number;
207
- /** Auto-scroll speed in pixels per frame at 60fps. @default 0.5 */
208
- speed?: number;
209
- /** Enable mouse wheel scrolling. @default true */
210
- wheelEnabled?: boolean;
211
- /**
212
- * Defines which wheel gestures ReactSway may cancel.
213
- *
214
- * `axis` only consumes wheel gestures aligned with the Sway axis, letting
215
- * cross-axis page scroll pass through. `capture` preserves legacy behavior
216
- * by consuming every wheel event that reaches the component.
217
- *
218
- * @default 'axis'
219
- */
220
- wheelMode?: SwayWheelMode;
221
- }
222
-
223
- /**
224
- * A smooth, infinite scrolling container component.
225
- *
226
- * Renders children in a continuously looping scroll area with support for
227
- * auto-scrolling, mouse drag, touch swipe, wheel, keyboard interactions,
228
- * and optional edge-hover scrolling.
229
- * Content is duplicated to create a seamless loop effect. Duplicate content
230
- * is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
231
- *
232
- * Respects `prefers-reduced-motion: reduce` by lowering auto-scroll speed
233
- * and disabling momentum effects.
234
- *
235
- * @example
236
- * ```tsx
237
- * <ReactSway direction="up" speed={1} friction={0.9}>
238
- * <div className="content-item">Item 1</div>
239
- * <div className="content-item">Item 2</div>
240
- * </ReactSway>
241
- * ```
242
- */
243
- function ReactSway({
244
- axis,
245
- autoScroll = true,
246
- children,
247
- direction = 'up',
248
- draggable = true,
249
- edgeHoverScroll = false,
250
- edgeHoverSize = DEFAULT_EDGE_HOVER_SIZE,
251
- friction = DEFAULT_FRICTION,
252
- keyboard = true,
253
- lazy = true,
254
- lazyRootMargin = DEFAULT_LAZY_ROOT_MARGIN,
255
- lazyThreshold = DEFAULT_LAZY_THRESHOLD,
256
- onPause,
257
- onResume,
258
- onScroll,
259
- pauseOnInteraction = true,
260
- resumeDelay = DEFAULT_RESUME_DELAY,
261
- speed = DEFAULT_SPEED,
262
- wheelEnabled = true,
263
- wheelMode = 'axis',
264
- }: ReactSwayProps) {
265
- const scrollAxis = useMemo<SwayAxis>(
266
- () => axis ?? (direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical'),
267
- [axis, direction],
268
- );
269
- const isHorizontal = scrollAxis === 'horizontal';
270
- const normalizedEdgeHoverSize = useMemo(
271
- () => (Number.isFinite(edgeHoverSize) ? Math.max(1, edgeHoverSize) : DEFAULT_EDGE_HOVER_SIZE),
272
- [edgeHoverSize],
273
- );
274
- const normalizedFriction = useMemo(
275
- () => (Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION),
276
- [friction],
277
- );
278
- const normalizedResumeDelay = useMemo(
279
- () => (Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY),
280
- [resumeDelay],
281
- );
282
- const normalizedSpeed = useMemo(
283
- () => (Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED),
284
- [speed],
285
- );
286
- const touchAction = useMemo(() => {
287
- if (!draggable) return 'auto';
288
- return isHorizontal ? 'pan-y' : 'pan-x';
289
- }, [draggable, isHorizontal]);
290
- // Containment is sound only when ReactSway owns every wheel event it receives;
291
- // otherwise cross-axis and disabled-wheel page scroll must remain chainable.
292
- const overscrollBehavior = wheelEnabled && wheelMode === 'capture' ? 'contain' : 'auto';
293
-
294
- const [isDragging, setIsDragging] = useState(false);
295
- const [isPaused, setIsPaused] = useState(false);
296
- const [isTabActive, setIsTabActive] = useState(true);
297
- const [loopPoint, setLoopPoint] = useState(0);
298
- const [loopSegmentCount, setLoopSegmentCount] = useState(MIN_LOOP_SEGMENTS);
299
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
300
- if (typeof window === 'undefined') return false;
301
- return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
302
- });
303
-
304
- const animationFrameRef = useRef<number | null>(null);
305
- const autoScrollRef = useRef({ active: autoScroll, desired: autoScroll });
306
- const containerRef = useRef<HTMLDivElement>(null);
307
- const inactivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
308
- const isDraggingRef = useRef(false);
309
- const isPausedRef = useRef(false);
310
- const lastFrameTimeRef = useRef(0);
311
- const lastMouseXRef = useRef(0);
312
- const lastMouseYRef = useRef(0);
313
- const lastTouchXRef = useRef(0);
314
- const lastTouchYRef = useRef(0);
315
- const loopPointRef = useRef(0);
316
- const edgeHoverDirectionRef = useRef(0);
317
- const onPauseRef = useRef(onPause);
318
- const onResumeRef = useRef(onResume);
319
- const onScrollRef = useRef(onScroll);
320
- const originalGroupRef = useRef<HTMLDivElement>(null);
321
- const positionRef = useRef(0);
322
- const resizeDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
323
- const touchInteractionStateRef = useRef<TouchInteractionState>('idle');
324
- const touchStartXRef = useRef(0);
325
- const touchStartYRef = useRef(0);
326
- const velocityRef = useRef(0);
327
-
328
- // Keep callback refs in sync without triggering re-renders
329
- useEffect(() => {
330
- onPauseRef.current = onPause;
331
- onResumeRef.current = onResume;
332
- onScrollRef.current = onScroll;
333
- }, [onPause, onResume, onScroll]);
334
-
335
- // Listen for prefers-reduced-motion media query changes
336
- useEffect(() => {
337
- if (typeof window === 'undefined') return;
338
- const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
339
- const handleChange = (e: MediaQueryListEvent) => {
340
- setPrefersReducedMotion(e.matches);
341
- };
342
- mediaQuery.addEventListener('change', handleChange);
343
- return () => {
344
- mediaQuery.removeEventListener('change', handleChange);
345
- };
346
- }, []);
347
-
348
- const clearInactivityTimer = useCallback(() => {
349
- if (!inactivityTimerRef.current) return;
350
- clearTimeout(inactivityTimerRef.current);
351
- inactivityTimerRef.current = null;
352
- }, []);
353
-
354
- const renderPosition = useCallback((rawPosition: number) => {
355
- if (!containerRef.current) return;
356
- const currentLoopPoint = loopPointRef.current;
357
- let visualPosition = rawPosition % (currentLoopPoint || 1);
358
- if (visualPosition > 0 && currentLoopPoint > 0) {
359
- visualPosition -= currentLoopPoint;
360
- }
361
- containerRef.current.style.transform = isHorizontal
362
- ? `translate3d(${visualPosition}px, 0, 0)`
363
- : `translate3d(0, ${visualPosition}px, 0)`;
364
- }, [isHorizontal]);
365
-
366
- const commitPosition = useCallback((nextPosition: number) => {
367
- if (positionRef.current === nextPosition) return;
368
- positionRef.current = nextPosition;
369
- renderPosition(nextPosition);
370
- onScrollRef.current?.(nextPosition);
371
- }, [renderPosition]);
372
-
373
- const wrapPosition = useCallback((rawPosition: number) => {
374
- const currentLoopPoint = loopPointRef.current;
375
- if (currentLoopPoint <= 0) return rawPosition;
376
-
377
- let wrappedPosition = rawPosition;
378
- while (wrappedPosition > 0) {
379
- wrappedPosition -= currentLoopPoint;
380
- }
381
- while (wrappedPosition < -currentLoopPoint * 2) {
382
- wrappedPosition += currentLoopPoint;
383
- }
384
- return wrappedPosition;
385
- }, []);
386
-
387
- const recalculateLoopPoint = useCallback(() => {
388
- if (!containerRef.current || !originalGroupRef.current) return;
389
- const originalContentSize = isHorizontal
390
- ? originalGroupRef.current.scrollWidth
391
- : originalGroupRef.current.scrollHeight;
392
- if (originalContentSize <= 0) return;
393
-
394
- const viewport = containerRef.current.parentElement ?? containerRef.current;
395
- const viewportSize = isHorizontal
396
- ? Math.max(viewport.clientWidth, 1)
397
- : Math.max(viewport.clientHeight, 1);
398
- const nextLoopSegmentCount = Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 2);
399
- const nextLoopPoint = originalContentSize;
400
-
401
- setLoopSegmentCount((previousLoopSegmentCount) =>
402
- previousLoopSegmentCount === nextLoopSegmentCount ? previousLoopSegmentCount : nextLoopSegmentCount
403
- );
404
- loopPointRef.current = nextLoopPoint;
405
- setLoopPoint((previousLoopPoint) => (Math.abs(previousLoopPoint - nextLoopPoint) < 0.5 ? previousLoopPoint : nextLoopPoint));
406
- renderPosition(positionRef.current);
407
- }, [isHorizontal, renderPosition]);
408
-
409
- // Sync autoScroll prop changes with internal state
410
- useEffect(() => {
411
- autoScrollRef.current.desired = autoScroll;
412
- if (!autoScroll) {
413
- clearInactivityTimer();
414
- autoScrollRef.current.active = true;
415
- }
416
- }, [autoScroll, clearInactivityTimer]);
417
-
418
- // Sync loopPoint ref after state updates from observers/resizes
419
- useEffect(() => {
420
- loopPointRef.current = loopPoint;
421
- renderPosition(positionRef.current);
422
- }, [loopPoint, renderPosition]);
423
-
424
- // Dimension calculation
425
- useEffect(() => {
426
- // Use RAF to ensure layout is complete
427
- const rafId = requestAnimationFrame(recalculateLoopPoint);
428
-
429
- return () => {
430
- cancelAnimationFrame(rafId);
431
- };
432
- }, [children, recalculateLoopPoint]);
433
-
434
- const pauseAutoScroll = useCallback(() => {
435
- if (!pauseOnInteraction) return;
436
- autoScrollRef.current.active = false;
437
- onPauseRef.current?.();
438
- clearInactivityTimer();
439
- }, [clearInactivityTimer, pauseOnInteraction]);
440
-
441
- const scheduleAutoScrollResume = useCallback(() => {
442
- if (!pauseOnInteraction || !autoScrollRef.current.desired || isPausedRef.current) return;
443
- clearInactivityTimer();
444
-
445
- inactivityTimerRef.current = setTimeout(() => {
446
- inactivityTimerRef.current = null;
447
- if (!autoScrollRef.current.desired || isPausedRef.current) return;
448
- autoScrollRef.current.active = true;
449
- onResumeRef.current?.();
450
- }, normalizedResumeDelay);
451
- }, [clearInactivityTimer, normalizedResumeDelay, pauseOnInteraction]);
452
-
453
- const togglePause = useCallback(() => {
454
- const newPaused = !isPausedRef.current;
455
- isPausedRef.current = newPaused;
456
- setIsPaused(newPaused);
457
- if (newPaused) {
458
- clearInactivityTimer();
459
- autoScrollRef.current.active = false;
460
- onPauseRef.current?.();
461
- } else {
462
- clearInactivityTimer();
463
- autoScrollRef.current.active = true;
464
- if (autoScrollRef.current.desired) {
465
- onResumeRef.current?.();
466
- }
467
- }
468
- }, [clearInactivityTimer]);
469
-
470
- const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
471
- if (!keyboard) return;
472
-
473
- switch (e.key) {
474
- case ' ':
475
- e.preventDefault();
476
- togglePause();
477
- break;
478
- case 'ArrowDown':
479
- if (isHorizontal) break;
480
- e.preventDefault();
481
- velocityRef.current -= ARROW_KEY_VELOCITY;
482
- pauseAutoScroll();
483
- scheduleAutoScrollResume();
484
- break;
485
- case 'ArrowUp':
486
- if (isHorizontal) break;
487
- e.preventDefault();
488
- velocityRef.current += ARROW_KEY_VELOCITY;
489
- pauseAutoScroll();
490
- scheduleAutoScrollResume();
491
- break;
492
- case 'ArrowLeft':
493
- if (!isHorizontal) break;
494
- e.preventDefault();
495
- velocityRef.current += ARROW_KEY_VELOCITY;
496
- pauseAutoScroll();
497
- scheduleAutoScrollResume();
498
- break;
499
- case 'ArrowRight':
500
- if (!isHorizontal) break;
501
- e.preventDefault();
502
- velocityRef.current -= ARROW_KEY_VELOCITY;
503
- pauseAutoScroll();
504
- scheduleAutoScrollResume();
505
- break;
506
- case 'End':
507
- e.preventDefault();
508
- if (loopPointRef.current > 0) {
509
- commitPosition(-loopPointRef.current);
510
- }
511
- velocityRef.current = 0;
512
- pauseAutoScroll();
513
- scheduleAutoScrollResume();
514
- break;
515
- case 'Home':
516
- e.preventDefault();
517
- commitPosition(0);
518
- velocityRef.current = 0;
519
- pauseAutoScroll();
520
- scheduleAutoScrollResume();
521
- break;
522
- default:
523
- break;
524
- }
525
- }, [commitPosition, isHorizontal, keyboard, pauseAutoScroll, scheduleAutoScrollResume, togglePause]);
526
-
527
- const handleMouseDown = useCallback((e: globalThis.MouseEvent) => {
528
- if (!draggable) return;
529
- e.preventDefault();
530
- containerRef.current?.focus();
531
- setIsDragging(true);
532
- isDraggingRef.current = true;
533
- lastMouseXRef.current = e.clientX;
534
- lastMouseYRef.current = e.clientY;
535
- velocityRef.current = 0;
536
- pauseAutoScroll();
537
- }, [draggable, pauseAutoScroll]);
538
-
539
- const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
540
- if (!isDraggingRef.current) return;
541
- e.preventDefault();
542
- const delta = isHorizontal ? e.clientX - lastMouseXRef.current : e.clientY - lastMouseYRef.current;
543
- const nextPosition = wrapPosition(positionRef.current + delta);
544
- commitPosition(nextPosition);
545
- velocityRef.current = delta;
546
- lastMouseXRef.current = e.clientX;
547
- lastMouseYRef.current = e.clientY;
548
- }, [commitPosition, isHorizontal, wrapPosition]);
549
-
550
- const handleMouseUp = useCallback((e: globalThis.MouseEvent) => {
551
- if (!isDraggingRef.current) return;
552
- e.preventDefault();
553
- setIsDragging(false);
554
- isDraggingRef.current = false;
555
- scheduleAutoScrollResume();
556
- }, [scheduleAutoScrollResume]);
557
-
558
- const handleTouchEnd = useCallback((_e: globalThis.TouchEvent) => {
559
- const wasActive = touchInteractionStateRef.current === 'active';
560
-
561
- touchInteractionStateRef.current = 'idle';
562
- if (isDraggingRef.current) {
563
- setIsDragging(false);
564
- isDraggingRef.current = false;
565
- }
566
-
567
- if (wasActive) {
568
- scheduleAutoScrollResume();
569
- }
570
- }, [scheduleAutoScrollResume]);
571
-
572
- const handleTouchMove = useCallback((e: globalThis.TouchEvent) => {
573
- if (touchInteractionStateRef.current === 'idle' || touchInteractionStateRef.current === 'ignored') return;
574
- if (e.touches.length !== 1) return;
575
-
576
- const touch = e.touches[0];
577
-
578
- if (touchInteractionStateRef.current === 'pending') {
579
- const totalDeltaX = touch.clientX - touchStartXRef.current;
580
- const totalDeltaY = touch.clientY - touchStartYRef.current;
581
- const primaryDelta = isHorizontal ? Math.abs(totalDeltaX) : Math.abs(totalDeltaY);
582
- const crossDelta = isHorizontal ? Math.abs(totalDeltaY) : Math.abs(totalDeltaX);
583
-
584
- if (Math.max(primaryDelta, crossDelta) < TOUCH_AXIS_LOCK_THRESHOLD_PX) return;
585
-
586
- if (crossDelta > primaryDelta) {
587
- touchInteractionStateRef.current = 'ignored';
588
- return;
589
- }
590
-
591
- touchInteractionStateRef.current = 'active';
592
- setIsDragging(true);
593
- isDraggingRef.current = true;
594
- pauseAutoScroll();
595
- }
596
-
597
- e.preventDefault();
598
- const delta = isHorizontal ? touch.clientX - lastTouchXRef.current : touch.clientY - lastTouchYRef.current;
599
- const nextPosition = wrapPosition(positionRef.current + delta);
600
- commitPosition(nextPosition);
601
- velocityRef.current = delta;
602
- lastTouchXRef.current = touch.clientX;
603
- lastTouchYRef.current = touch.clientY;
604
- }, [commitPosition, isHorizontal, pauseAutoScroll, wrapPosition]);
605
-
606
- const handleTouchStart = useCallback((e: globalThis.TouchEvent) => {
607
- if (!draggable || e.touches.length !== 1) return;
608
- containerRef.current?.focus();
609
- touchInteractionStateRef.current = 'pending';
610
- lastTouchXRef.current = e.touches[0].clientX;
611
- lastTouchYRef.current = e.touches[0].clientY;
612
- touchStartXRef.current = e.touches[0].clientX;
613
- touchStartYRef.current = e.touches[0].clientY;
614
- velocityRef.current = 0;
615
- }, [draggable]);
616
-
617
- const handleWheel = useCallback((e: globalThis.WheelEvent) => {
618
- if (!wheelEnabled) return;
619
- const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
620
- if (!currentContainer) return;
621
-
622
- const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
623
- if (ownedDelta === null) return;
624
-
625
- e.preventDefault();
626
- const wheelDelta = -ownedDelta;
627
- const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
628
- commitPosition(nextPosition);
629
- velocityRef.current += wheelDelta * WHEEL_VELOCITY_MULTIPLIER;
630
- velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
631
- pauseAutoScroll();
632
- scheduleAutoScrollResume();
633
- }, [commitPosition, isHorizontal, pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled, wheelMode, wrapPosition]);
634
-
635
- const handleEdgeHoverMove = useCallback((e: globalThis.MouseEvent) => {
636
- if (!edgeHoverScroll) return;
637
- const viewport = containerRef.current?.parentElement ?? containerRef.current;
638
- if (!viewport) return;
639
-
640
- const rect = viewport.getBoundingClientRect();
641
- const visibleInterval = getVisibleEdgeInterval(rect, isHorizontal);
642
- if (!visibleInterval) {
643
- edgeHoverDirectionRef.current = 0;
644
- return;
645
- }
646
-
647
- const pointerPosition = isHorizontal ? e.clientX : e.clientY;
648
- const effectiveEdgeHoverSize = Math.min(normalizedEdgeHoverSize, visibleInterval.size / 2);
649
-
650
- if (pointerPosition <= visibleInterval.start + effectiveEdgeHoverSize) {
651
- edgeHoverDirectionRef.current = 1;
652
- return;
653
- }
654
-
655
- if (pointerPosition >= visibleInterval.end - effectiveEdgeHoverSize) {
656
- edgeHoverDirectionRef.current = -1;
657
- return;
658
- }
659
-
660
- edgeHoverDirectionRef.current = 0;
661
- }, [edgeHoverScroll, isHorizontal, normalizedEdgeHoverSize]);
662
-
663
- const handleEdgeHoverLeave = useCallback(() => {
664
- edgeHoverDirectionRef.current = 0;
665
- }, []);
666
-
667
- // Event listener registration
668
- useEffect(() => {
669
- const currentContainer = containerRef.current;
670
- if (!currentContainer) return;
671
-
672
- currentContainer.addEventListener('mousedown', handleMouseDown);
673
- window.addEventListener('mousemove', handleMouseMove);
674
- window.addEventListener('mouseup', handleMouseUp);
675
- if (draggable) {
676
- currentContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
677
- window.addEventListener('touchmove', handleTouchMove, { passive: false });
678
- window.addEventListener('touchend', handleTouchEnd, { passive: true });
679
- }
680
- if (wheelEnabled) {
681
- currentContainer.addEventListener('wheel', handleWheel, { passive: false });
682
- }
683
- const edgeHoverTarget = currentContainer.parentElement ?? currentContainer;
684
- edgeHoverTarget.addEventListener('mousemove', handleEdgeHoverMove);
685
- edgeHoverTarget.addEventListener('mouseleave', handleEdgeHoverLeave);
686
-
687
- return () => {
688
- currentContainer.removeEventListener('mousedown', handleMouseDown);
689
- window.removeEventListener('mousemove', handleMouseMove);
690
- window.removeEventListener('mouseup', handleMouseUp);
691
- if (draggable) {
692
- currentContainer.removeEventListener('touchstart', handleTouchStart);
693
- window.removeEventListener('touchmove', handleTouchMove);
694
- window.removeEventListener('touchend', handleTouchEnd);
695
- }
696
- if (wheelEnabled) {
697
- currentContainer.removeEventListener('wheel', handleWheel);
698
- }
699
- edgeHoverTarget.removeEventListener('mousemove', handleEdgeHoverMove);
700
- edgeHoverTarget.removeEventListener('mouseleave', handleEdgeHoverLeave);
701
- };
702
- }, [draggable, handleEdgeHoverLeave, handleEdgeHoverMove, handleMouseDown, handleMouseMove, handleMouseUp, handleTouchEnd, handleTouchMove, handleTouchStart, handleWheel, wheelEnabled]);
703
-
704
- // Debounced resize handler shared by ResizeObserver and window resize fallback
705
- const debouncedRecalculate = useCallback(() => {
706
- if (resizeDebounceTimerRef.current) {
707
- clearTimeout(resizeDebounceTimerRef.current);
708
- }
709
- resizeDebounceTimerRef.current = setTimeout(() => {
710
- resizeDebounceTimerRef.current = null;
711
- recalculateLoopPoint();
712
- }, RESIZE_DEBOUNCE_MS);
713
- }, [recalculateLoopPoint]);
714
-
715
- // Resize listener fallback for browsers without ResizeObserver
716
- useEffect(() => {
717
- window.addEventListener('resize', debouncedRecalculate);
718
-
719
- return () => {
720
- window.removeEventListener('resize', debouncedRecalculate);
721
- };
722
- }, [debouncedRecalculate]);
723
-
724
- // ResizeObserver keeps loop measurements in sync with async content changes
725
- useEffect(() => {
726
- if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
727
-
728
- const observer = new ResizeObserver(() => {
729
- debouncedRecalculate();
730
- });
731
- observer.observe(containerRef.current);
732
-
733
- return () => {
734
- observer.disconnect();
735
- };
736
- }, [debouncedRecalculate]);
737
-
738
- // Clean up resize debounce timer on unmount
739
- useEffect(() => {
740
- return () => {
741
- if (resizeDebounceTimerRef.current) {
742
- clearTimeout(resizeDebounceTimerRef.current);
743
- resizeDebounceTimerRef.current = null;
744
- }
745
- };
746
- }, []);
747
-
748
- // Tab visibility handling
749
- useEffect(() => {
750
- const handleVisibilityChange = () => {
751
- setIsTabActive(!document.hidden);
752
- if (!document.hidden) {
753
- lastFrameTimeRef.current = performance.now();
754
- }
755
- };
756
-
757
- document.addEventListener('visibilitychange', handleVisibilityChange);
758
- return () => {
759
- document.removeEventListener('visibilitychange', handleVisibilityChange);
760
- };
761
- }, []);
762
-
763
- // Clean up inactivity timer on unmount
764
- useEffect(() => {
765
- return () => {
766
- clearInactivityTimer();
767
- };
768
- }, [clearInactivityTimer]);
769
-
770
- // Animation loop
771
- useEffect(() => {
772
- if (!isTabActive || isPaused) {
773
- if (animationFrameRef.current) {
774
- cancelAnimationFrame(animationFrameRef.current);
775
- animationFrameRef.current = null;
776
- }
777
- return;
778
- }
779
-
780
- // Reset so the first frame uses deltaTime=1 instead of a stale timestamp
781
- lastFrameTimeRef.current = 0;
782
-
783
- const directionMultiplier = direction === 'down' || direction === 'right' ? 1 : -1;
784
-
785
- const animate = (currentTime: number) => {
786
- let deltaTime = lastFrameTimeRef.current
787
- ? (currentTime - lastFrameTimeRef.current) / MS_PER_FRAME_60FPS
788
- : 1;
789
- deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
790
- lastFrameTimeRef.current = currentTime;
791
-
792
- // Apply velocity damping (skip momentum in reduced-motion mode)
793
- if (prefersReducedMotion) {
794
- velocityRef.current = 0;
795
- } else if (Math.abs(velocityRef.current) > 0.1) {
796
- velocityRef.current *= Math.pow(normalizedFriction, deltaTime);
797
- } else {
798
- velocityRef.current = 0;
799
- }
800
-
801
- let nextPosition = positionRef.current;
802
-
803
- // Calculate effective speed (reduced when user prefers reduced motion)
804
- const effectiveSpeed = prefersReducedMotion
805
- ? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR
806
- : normalizedSpeed;
807
-
808
- const edgeHoverMultiplier = edgeHoverScroll ? edgeHoverDirectionRef.current : directionMultiplier;
809
-
810
- // Auto-scroll when enabled and not dragging
811
- if (
812
- autoScrollRef.current.desired &&
813
- autoScrollRef.current.active &&
814
- !isDraggingRef.current &&
815
- (!edgeHoverScroll || edgeHoverMultiplier !== 0)
816
- ) {
817
- nextPosition += edgeHoverMultiplier * effectiveSpeed * deltaTime;
818
- }
819
-
820
- // Apply velocity momentum from user interaction
821
- if (!isDraggingRef.current && Math.abs(velocityRef.current) > 0.1) {
822
- nextPosition += velocityRef.current * deltaTime;
823
- }
824
-
825
- nextPosition = wrapPosition(nextPosition);
826
- commitPosition(nextPosition);
827
-
828
- animationFrameRef.current = requestAnimationFrame(animate);
829
- };
830
-
831
- animationFrameRef.current = requestAnimationFrame(animate);
832
-
833
- return () => {
834
- if (animationFrameRef.current) {
835
- cancelAnimationFrame(animationFrameRef.current);
836
- }
837
- };
838
- }, [commitPosition, direction, edgeHoverScroll, isHorizontal, isPaused, isTabActive, normalizedFriction, normalizedSpeed, prefersReducedMotion, wrapPosition]);
839
-
840
- // Intersection Observer for lazy visibility detection
841
- useEffect(() => {
842
- if (!lazy || !containerRef.current || typeof IntersectionObserver === 'undefined') return;
843
-
844
- const observer = new IntersectionObserver(
845
- (entries) => {
846
- entries.forEach((entry) => {
847
- if (entry.isIntersecting) {
848
- entry.target.classList.add('visible');
849
- }
850
- });
851
- },
852
- {
853
- root: null,
854
- rootMargin: lazyRootMargin,
855
- threshold: lazyThreshold,
856
- }
857
- );
858
-
859
- const items = containerRef.current.querySelectorAll('.content-item');
860
- items.forEach((item) => observer.observe(item));
861
-
862
- return () => {
863
- items.forEach((item) => observer.unobserve(item));
864
- observer.disconnect();
865
- };
866
- }, [children, lazy, lazyRootMargin, lazyThreshold]);
867
-
868
- return (
869
- <div
870
- className="react-sway-container scroller-content"
871
- ref={containerRef}
872
- style={{
873
- cursor: draggable ? (isDragging ? 'grabbing' : 'grab') : 'default',
874
- display: isHorizontal ? 'flex' : undefined,
875
- flexDirection: isHorizontal ? 'row' : undefined,
876
- height: isHorizontal ? '100%' : 'max-content',
877
- MozUserSelect: 'none',
878
- msUserSelect: 'none',
879
- minHeight: isHorizontal ? undefined : '100%',
880
- overflow: 'hidden',
881
- overscrollBehavior,
882
- pointerEvents: 'auto',
883
- position: 'absolute',
884
- touchAction,
885
- transform: 'translate3d(0, 0, 0)',
886
- userSelect: 'none',
887
- WebkitUserSelect: 'none',
888
- width: isHorizontal ? 'max-content' : '100%',
889
- willChange: 'transform',
890
- zIndex: 1,
891
- }}
892
- onKeyDown={keyboard ? handleKeyDown : undefined}
893
- tabIndex={keyboard ? 0 : undefined}
894
- >
895
- <div className="content-group original" ref={originalGroupRef} style={isHorizontal ? { display: 'flex', flex: '0 0 auto' } : undefined}>
896
- {children}
897
- </div>
898
- {Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => (
899
- <aside
900
- aria-hidden="true"
901
- className="content-group duplicate"
902
- data-duplicate="true"
903
- key={`duplicate-${duplicateIndex}`}
904
- role="presentation"
905
- style={isHorizontal ? { display: 'flex', flex: '0 0 auto' } : undefined}
906
- >
907
- {children}
908
- </aside>
909
- ))}
910
- </div>
911
- );
912
- }
913
-
914
- export default ReactSway;