react-sway 0.4.1 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3 - 2026-10-21
4
+
5
+ - Preserve browser zoom gestures, nested controls, and secondary mouse clicks.
6
+ - Stop external edge-hover motion when edge-hover mode is disabled.
7
+ - Preserve inert duplicate groups on React 17 and 18 as well as React 19.
8
+ - Update the development toolchain; keep TypeScript 6 for parser compatibility.
9
+
3
10
  ## 0.4.0
4
11
 
5
12
  - Added horizontal and vertical axis semantics with page-safe touch behavior.
package/README.md CHANGED
@@ -16,7 +16,8 @@ It works by duplicating your content to create a seamless loop and uses CSS tran
16
16
  * **User Friendly Interactions:**
17
17
  * Click and drag to scroll.
18
18
  * Swipe on touch devices.
19
- * Mouse wheel support with axis-aware intent detection, delta-mode normalization, and velocity capping.
19
+ * Mouse wheel support with axis-aware intent detection, delta-mode normalization, and velocity capping. Browser zoom gestures pass through.
20
+ * Nested buttons, links, and editable fields retain their native interactions.
20
21
  * Keyboard controls: Spacebar to pause/resume, Arrow keys to scroll, Home/End to jump.
21
22
  * **External Input Bridge:** Use `ref` with `ReactSwayHandle` to route wheel and edge-hover intent from a parent drag/drop surface into Sway.
22
23
  * **Idle-Safe Listeners:** Global drag listeners are attached only during active drag or touch gestures and cleaned up on release, cancel, blur, and unmount.
@@ -290,12 +291,12 @@ function MinimalEdgeHoverExamples() {
290
291
  | `edgeHoverInputMode` | `'external' \| 'hybrid'` | `'hybrid'` | Use intrinsic pointer sensing plus the imperative bridge, or external imperative ownership only for composed drag surfaces. |
291
292
  | `edgeHoverScroll` | `boolean` | `false` | Only auto-scroll while hovering the active axis boundary: top/bottom for vertical, left/right for horizontal. A short ingress feather removes sensor chatter; velocity then accelerates through the physical edge and reaches its bounded cap one activation-zone width beyond it. |
292
293
  | `edgeHoverSize` | `number` | `96` | Thickness in pixels of each edge-hover activation zone. |
293
- | `edgeHoverSpeedMultiplier` | `number` | `6` | Maximum edge-hover velocity as a multiple of `speed`. The physical edge retains the established 3x response, then accelerates to the bounded `1`–`10` maximum beyond the viewport; set `1` for constant speed. |
294
+ | `edgeHoverSpeedMultiplier` | `number` | `6` | Maximum edge-hover velocity as a multiple of `speed`. The physical edge retains the established 3x response, then accelerates to the bounded `1`–`10` maximum beyond the viewport; set `1` for constant speed. |
294
295
  | `friction` | `number` | `0.95` | Momentum decay coefficient (0-1, lower = more friction). |
295
296
  | `keyboard` | `boolean` | `true` | Enable keyboard controls (Space, Arrow keys, Home/End). |
296
297
  | `lazy` | `boolean` | `true` | Enable lazy visibility detection via IntersectionObserver. |
297
298
  | `lazyRootMargin` | `string` | `'100px'` | IntersectionObserver `rootMargin` for lazy visibility detection. |
298
- | `lazyThreshold` | `number` | `0.01` | IntersectionObserver `threshold`, normalized to the inclusive `0`–`1` interval. |
299
+ | `lazyThreshold` | `number` | `0.01` | IntersectionObserver `threshold`, normalized to the inclusive `0`–`1` interval. |
299
300
  | `onPause` | `() => void` | - | Fired once when active autonomous scrolling transitions to interaction-paused. |
300
301
  | `onResume` | `() => void` | - | Fired when interaction-paused autonomous scrolling resumes. |
301
302
  | `onScroll` | `(position: number) => void` | - | Fired on every position change with the current scroll position. |
package/dist/index.cjs CHANGED
@@ -50,10 +50,10 @@ function getEdgeHoverVelocityScale(intensity, speedMultiplier = 6) {
50
50
  if (depth < EDGE_HOVER_ENTRY_FEATHER_INTENSITY) return direction * smoothUnitStep(depth / EDGE_HOVER_ENTRY_FEATHER_INTENSITY);
51
51
  const physicalEdgeScale = Math.min(EDGE_HOVER_PHYSICAL_EDGE_SPEED_MULTIPLIER, maximumScale);
52
52
  if (depth <= EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) {
53
- const physicalEdgeProgress = (depth - EDGE_HOVER_ENTRY_FEATHER_INTENSITY) / (EDGE_HOVER_PHYSICAL_EDGE_INTENSITY - EDGE_HOVER_ENTRY_FEATHER_INTENSITY);
53
+ const physicalEdgeProgress = (depth - EDGE_HOVER_ENTRY_FEATHER_INTENSITY) / .42;
54
54
  return direction * (1 + (physicalEdgeScale - 1) * smoothUnitStep(physicalEdgeProgress));
55
55
  }
56
- const beyondEdgeProgress = (depth - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) / (1 - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY);
56
+ const beyondEdgeProgress = (depth - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) / .5;
57
57
  return direction * (physicalEdgeScale + (maximumScale - physicalEdgeScale) * smoothUnitStep(beyondEdgeProgress));
58
58
  }
59
59
  /**
@@ -115,8 +115,10 @@ function normalizeWheelDelta(event, container, axis) {
115
115
  * the gesture. This partial function is the wheel-ownership boundary.
116
116
  */
117
117
  function getOwnedWheelDelta(event, container, isHorizontal, wheelMode) {
118
+ if (event.ctrlKey) return null;
118
119
  const deltaX = normalizeWheelDelta(event, container, "x");
119
120
  const deltaY = normalizeWheelDelta(event, container, "y");
121
+ if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY) || deltaX === 0 && deltaY === 0) return null;
120
122
  const absDeltaX = Math.abs(deltaX);
121
123
  const absDeltaY = Math.abs(deltaY);
122
124
  if (isHorizontal) {
@@ -161,6 +163,10 @@ var WHEEL_VELOCITY_MULTIPLIER = .14;
161
163
  var WHEEL_IMMEDIATE_DELTA_FACTOR = .14;
162
164
  /** Minimum touch movement before a gesture is classified by axis. */
163
165
  var TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
166
+ var INERT_ATTRIBUTE = { inert: Number.parseInt(react.version, 10) >= 19 ? true : "" };
167
+ function isInteractiveTarget(target) {
168
+ return target instanceof Element && Boolean(target.closest("a, button, input, select, textarea, [contenteditable]:not([contenteditable=\"false\"])"));
169
+ }
164
170
  function getVisibleEdgeInterval(rect, isHorizontal) {
165
171
  const rectEnd = isHorizontal ? rect.right : rect.bottom;
166
172
  const rectStart = isHorizontal ? rect.left : rect.top;
@@ -421,7 +427,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
421
427
  touchDragListenerCleanupRef.current = null;
422
428
  }, []);
423
429
  const handleKeyDown = (0, react.useCallback)((e) => {
424
- if (!keyboard) return;
430
+ if (!keyboard || isInteractiveTarget(e.target)) return;
425
431
  switch (e.key) {
426
432
  case " ":
427
433
  e.preventDefault();
@@ -468,8 +474,6 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
468
474
  velocityRef.current = 0;
469
475
  pauseAutoScroll();
470
476
  scheduleAutoScrollResume();
471
- break;
472
- default: break;
473
477
  }
474
478
  startAnimationLoop();
475
479
  }, [
@@ -537,7 +541,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
537
541
  handleMouseUp
538
542
  ]);
539
543
  const handleMouseDown = (0, react.useCallback)((e) => {
540
- if (!draggable) return;
544
+ if (!draggable || e.button !== 0 || isInteractiveTarget(e.target)) return;
541
545
  e.preventDefault();
542
546
  containerRef.current?.focus();
543
547
  setDraggingState(true);
@@ -617,7 +621,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
617
621
  handleTouchMove
618
622
  ]);
619
623
  const handleTouchStart = (0, react.useCallback)((e) => {
620
- if (!draggable || e.touches.length !== 1) return;
624
+ if (!draggable || e.touches.length !== 1 || isInteractiveTarget(e.target)) return;
621
625
  containerRef.current?.focus();
622
626
  touchInteractionStateRef.current = "pending";
623
627
  lastTouchXRef.current = e.touches[0].clientX;
@@ -628,7 +632,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
628
632
  attachTouchDragListeners();
629
633
  }, [attachTouchDragListeners, draggable]);
630
634
  const handleWheel = (0, react.useCallback)((e) => {
631
- if (!wheelEnabled) return false;
635
+ if (!wheelEnabled || e.defaultPrevented) return false;
632
636
  const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
633
637
  if (!currentContainer) return false;
634
638
  const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
@@ -681,6 +685,9 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
681
685
  externalEdgeHoverLeaseRef.current = false;
682
686
  setEdgeHoverIntensity(0);
683
687
  }, [setEdgeHoverIntensity]);
688
+ (0, react.useEffect)(() => {
689
+ if (!edgeHoverScroll) clearEdgeHover();
690
+ }, [clearEdgeHover, edgeHoverScroll]);
684
691
  (0, react.useImperativeHandle)(ref, () => ({
685
692
  clearEdgeHover,
686
693
  handleEdgeHover,
@@ -911,19 +918,19 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
911
918
  flex: "0 0 auto"
912
919
  } : void 0,
913
920
  children
914
- }), Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("aside", {
921
+ }), Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ (0, react.createElement)("aside", {
915
922
  "aria-hidden": "true",
916
923
  className: "content-group duplicate",
917
924
  "data-duplicate": "true",
918
- inert: true,
925
+ ...INERT_ATTRIBUTE,
926
+ key: `duplicate-${duplicateIndex}`,
919
927
  role: "presentation",
920
928
  style: isHorizontal ? {
921
929
  display: "flex",
922
930
  flex: "0 0 auto",
923
931
  pointerEvents: "none"
924
- } : { pointerEvents: "none" },
925
- children
926
- }, `duplicate-${duplicateIndex}`))]
932
+ } : { pointerEvents: "none" }
933
+ }, children))]
927
934
  });
928
935
  });
929
936
  //#endregion
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
1
+ import { createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, version } from "react";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  //#region src/edge-hover-velocity.ts
4
4
  /**
@@ -49,10 +49,10 @@ function getEdgeHoverVelocityScale(intensity, speedMultiplier = 6) {
49
49
  if (depth < EDGE_HOVER_ENTRY_FEATHER_INTENSITY) return direction * smoothUnitStep(depth / EDGE_HOVER_ENTRY_FEATHER_INTENSITY);
50
50
  const physicalEdgeScale = Math.min(EDGE_HOVER_PHYSICAL_EDGE_SPEED_MULTIPLIER, maximumScale);
51
51
  if (depth <= EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) {
52
- const physicalEdgeProgress = (depth - EDGE_HOVER_ENTRY_FEATHER_INTENSITY) / (EDGE_HOVER_PHYSICAL_EDGE_INTENSITY - EDGE_HOVER_ENTRY_FEATHER_INTENSITY);
52
+ const physicalEdgeProgress = (depth - EDGE_HOVER_ENTRY_FEATHER_INTENSITY) / .42;
53
53
  return direction * (1 + (physicalEdgeScale - 1) * smoothUnitStep(physicalEdgeProgress));
54
54
  }
55
- const beyondEdgeProgress = (depth - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) / (1 - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY);
55
+ const beyondEdgeProgress = (depth - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) / .5;
56
56
  return direction * (physicalEdgeScale + (maximumScale - physicalEdgeScale) * smoothUnitStep(beyondEdgeProgress));
57
57
  }
58
58
  /**
@@ -114,8 +114,10 @@ function normalizeWheelDelta(event, container, axis) {
114
114
  * the gesture. This partial function is the wheel-ownership boundary.
115
115
  */
116
116
  function getOwnedWheelDelta(event, container, isHorizontal, wheelMode) {
117
+ if (event.ctrlKey) return null;
117
118
  const deltaX = normalizeWheelDelta(event, container, "x");
118
119
  const deltaY = normalizeWheelDelta(event, container, "y");
120
+ if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY) || deltaX === 0 && deltaY === 0) return null;
119
121
  const absDeltaX = Math.abs(deltaX);
120
122
  const absDeltaY = Math.abs(deltaY);
121
123
  if (isHorizontal) {
@@ -160,6 +162,10 @@ var WHEEL_VELOCITY_MULTIPLIER = .14;
160
162
  var WHEEL_IMMEDIATE_DELTA_FACTOR = .14;
161
163
  /** Minimum touch movement before a gesture is classified by axis. */
162
164
  var TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
165
+ var INERT_ATTRIBUTE = { inert: Number.parseInt(version, 10) >= 19 ? true : "" };
166
+ function isInteractiveTarget(target) {
167
+ return target instanceof Element && Boolean(target.closest("a, button, input, select, textarea, [contenteditable]:not([contenteditable=\"false\"])"));
168
+ }
163
169
  function getVisibleEdgeInterval(rect, isHorizontal) {
164
170
  const rectEnd = isHorizontal ? rect.right : rect.bottom;
165
171
  const rectStart = isHorizontal ? rect.left : rect.top;
@@ -420,7 +426,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
420
426
  touchDragListenerCleanupRef.current = null;
421
427
  }, []);
422
428
  const handleKeyDown = useCallback((e) => {
423
- if (!keyboard) return;
429
+ if (!keyboard || isInteractiveTarget(e.target)) return;
424
430
  switch (e.key) {
425
431
  case " ":
426
432
  e.preventDefault();
@@ -467,8 +473,6 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
467
473
  velocityRef.current = 0;
468
474
  pauseAutoScroll();
469
475
  scheduleAutoScrollResume();
470
- break;
471
- default: break;
472
476
  }
473
477
  startAnimationLoop();
474
478
  }, [
@@ -536,7 +540,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
536
540
  handleMouseUp
537
541
  ]);
538
542
  const handleMouseDown = useCallback((e) => {
539
- if (!draggable) return;
543
+ if (!draggable || e.button !== 0 || isInteractiveTarget(e.target)) return;
540
544
  e.preventDefault();
541
545
  containerRef.current?.focus();
542
546
  setDraggingState(true);
@@ -616,7 +620,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
616
620
  handleTouchMove
617
621
  ]);
618
622
  const handleTouchStart = useCallback((e) => {
619
- if (!draggable || e.touches.length !== 1) return;
623
+ if (!draggable || e.touches.length !== 1 || isInteractiveTarget(e.target)) return;
620
624
  containerRef.current?.focus();
621
625
  touchInteractionStateRef.current = "pending";
622
626
  lastTouchXRef.current = e.touches[0].clientX;
@@ -627,7 +631,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
627
631
  attachTouchDragListeners();
628
632
  }, [attachTouchDragListeners, draggable]);
629
633
  const handleWheel = useCallback((e) => {
630
- if (!wheelEnabled) return false;
634
+ if (!wheelEnabled || e.defaultPrevented) return false;
631
635
  const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
632
636
  if (!currentContainer) return false;
633
637
  const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
@@ -680,6 +684,9 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
680
684
  externalEdgeHoverLeaseRef.current = false;
681
685
  setEdgeHoverIntensity(0);
682
686
  }, [setEdgeHoverIntensity]);
687
+ useEffect(() => {
688
+ if (!edgeHoverScroll) clearEdgeHover();
689
+ }, [clearEdgeHover, edgeHoverScroll]);
683
690
  useImperativeHandle(ref, () => ({
684
691
  clearEdgeHover,
685
692
  handleEdgeHover,
@@ -910,19 +917,19 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
910
917
  flex: "0 0 auto"
911
918
  } : void 0,
912
919
  children
913
- }), Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ jsx("aside", {
920
+ }), Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ createElement("aside", {
914
921
  "aria-hidden": "true",
915
922
  className: "content-group duplicate",
916
923
  "data-duplicate": "true",
917
- inert: true,
924
+ ...INERT_ATTRIBUTE,
925
+ key: `duplicate-${duplicateIndex}`,
918
926
  role: "presentation",
919
927
  style: isHorizontal ? {
920
928
  display: "flex",
921
929
  flex: "0 0 auto",
922
930
  pointerEvents: "none"
923
- } : { pointerEvents: "none" },
924
- children
925
- }, `duplicate-${duplicateIndex}`))]
931
+ } : { pointerEvents: "none" }
932
+ }, children))]
926
933
  });
927
934
  });
928
935
  //#endregion
package/package.json CHANGED
@@ -16,18 +16,18 @@
16
16
  }
17
17
  },
18
18
  "devDependencies": {
19
- "@testing-library/jest-dom": "^6.9.1",
20
- "@testing-library/react": "^16.3.2",
21
- "@types/react": "^19.2.17",
22
- "@types/react-dom": "^19.2.3",
23
- "eslint": "^10.7.0",
19
+ "@testing-library/jest-dom": "^7.0.1",
20
+ "@testing-library/react": "^16.3.3",
21
+ "@types/react": "^19.3.0",
22
+ "@types/react-dom": "^19.3.0",
23
+ "eslint": "^10.13.0",
24
24
  "eslint-plugin-react-hooks": "^7.1.1",
25
- "jsdom": "^29.1.1",
26
- "react": "^19.2.7",
27
- "react-dom": "^19.2.7",
25
+ "jsdom": "^30.1.3",
26
+ "react": "^19.3.1",
27
+ "react-dom": "^19.3.1",
28
28
  "typescript": "^6.0.3",
29
- "vite": "^8.1.5",
30
- "vitest": "^4.1.10"
29
+ "vite": "^8.3.4",
30
+ "vitest": "^5.0.5"
31
31
  },
32
32
  "files": [
33
33
  "CHANGELOG.md",
@@ -74,5 +74,5 @@
74
74
  },
75
75
  "type": "module",
76
76
  "types": "./dist/index.d.ts",
77
- "version": "0.4.1"
77
+ "version": "0.4.3"
78
78
  }