@aiquants/virtualscroll 2.6.0 → 2.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/virtualscroll",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "High-performance virtual scrolling component for React with variable item heights",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -99,6 +99,21 @@ export type ScrollPaneProps = {
99
99
  renderThumbOverlay?: (props: ScrollBarThumbOverlayRenderProps) => React.ReactNode
100
100
  /** Multiplier applied to wheel delta for faster or slower scrolling. / スクロール速度を調整するためのホイールデルタの倍率。 */
101
101
  wheelSpeedMultiplier?: number
102
+ /**
103
+ * How wheel input behaves at the pane's edges (default: "auto").
104
+ * ペインの端でホイールがどう振る舞うか (既定: "auto")。
105
+ *
106
+ * - `"auto"` (既定): その向きへ**もう動けない**縦ホイールは消費せず、祖先 (ページ) の
107
+ * スクロールへ連鎖させる。ネイティブのスクロール領域と同じ挙動
108
+ * (CSS `overscroll-behavior: auto` 相当)。端に**達する**ノッチは部分消費して端で止まり、
109
+ * **次の**ノッチから連鎖する (ネイティブと同じ段付き)。
110
+ * - `"contain"`: 端でも消費し続け、ページへ連鎖させない (2.6.0 以前の挙動)。
111
+ * モーダル内の一覧など「背後を絶対に動かしたくない」消費側向け。
112
+ *
113
+ * ❗ 対象は**縦ホイールのみ**。横成分は `onWheelHorizontal` の所有者 (消費側) の責務であり、
114
+ * ポインタドラッグ / タッチの連鎖はネイティブ相当の慣性譲渡が別問題のため対象外。
115
+ */
116
+ overscrollBehavior?: "auto" | "contain"
102
117
  /**
103
118
  * Callback delegating horizontal wheel/trackpad delta to an upstream owner.
104
119
  * When provided, horizontal-dominant (or shift+wheel) gestures are consumed here
@@ -287,6 +302,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
287
302
  itemCount,
288
303
  renderThumbOverlay,
289
304
  wheelSpeedMultiplier = 1,
305
+ overscrollBehavior = "auto",
290
306
  onWheelHorizontal,
291
307
  contentInsets,
292
308
  visibleStartIndex,
@@ -773,11 +789,11 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
773
789
  // ❗ **代入のタイミングは `sizeRef` と必ず揃える (どちらも layout effect のみ)。**
774
790
  // 片方だけ render 本体に残すと、中断されたトランジション中に `applyWheel` と `scrollTo` が
775
791
  // 別々の世界の寸法で判断する最悪の組み合わせになる。
776
- const wheelPolicyRef = useRef({ isScrollable, viewportSize, wheelSpeedMultiplier })
792
+ const wheelPolicyRef = useRef({ isScrollable, viewportSize, wheelSpeedMultiplier, overscrollBehavior })
777
793
  useLayoutEffect(() => {
778
794
  // sizeRef と同じ理由で「最後に commit された値」だけを保持する (A-1。sizeRef のコメント参照)
779
- wheelPolicyRef.current = { isScrollable, viewportSize, wheelSpeedMultiplier }
780
- }, [isScrollable, viewportSize, wheelSpeedMultiplier])
795
+ wheelPolicyRef.current = { isScrollable, viewportSize, wheelSpeedMultiplier, overscrollBehavior }
796
+ }, [isScrollable, viewportSize, wheelSpeedMultiplier, overscrollBehavior])
781
797
 
782
798
  /**
783
799
  * Applies one wheel event with the pane's own rules and reports whether it was consumed.
@@ -797,7 +813,11 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
797
813
  * 2. 横優勢 (shift+ホイールを含む) は `onWheelHorizontal` へ委譲。未指定なら消費せず祖先へ委ねる
798
814
  * 3. スクロール不能なら消費しない
799
815
  * 4. px 換算後の縦成分が 0 なら消費しない (適用できる量が無いのに祖先のスクロールを奪わない)
800
- * 5. 消費済みの印 + `preventDefault()` + 慣性停止 + 速度倍率を掛けて相対スクロール
816
+ * 5. 実効 delta の確定 (速度倍率を先に適用)
817
+ * 6. `overscrollBehavior` が "contain" でなければ端判定: その向きへもう動けなければ
818
+ * 消費せず祖先へ連鎖 (2.7.0)
819
+ * 7. 消費済みの印 + `preventDefault()` + 慣性停止
820
+ * 8. 相対スクロール
801
821
  *
802
822
  * @param event - The wheel event to apply / 適用するホイールイベント
803
823
  * @returns True when this pane applied the event / このペインが適用した場合に true。
@@ -823,7 +843,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
823
843
  // 保持済みハンドルは preventDefault し続け、ページのスクロールを殺す)。
824
844
  // `VirtualScrollHandle.applyWheel` は毎回 ref を辿るため凍結しない。両者の
825
845
  // 非対称は `WheelBridgeTarget` が両方を等価に扱う設計と噛み合わない。
826
- const { isScrollable: currentIsScrollable, viewportSize: currentViewportSize, wheelSpeedMultiplier: currentWheelSpeedMultiplier } = wheelPolicyRef.current
846
+ const { isScrollable: currentIsScrollable, viewportSize: currentViewportSize, wheelSpeedMultiplier: currentWheelSpeedMultiplier, overscrollBehavior: currentOverscrollBehavior } = wheelPolicyRef.current
827
847
  const axes = resolveWheelAxes(event, currentViewportSize)
828
848
 
829
849
  // ❗ ctrl+wheel はブラウザのズーム (ピンチズーム/ctrl+shift+ホイール等) なので横取りせず常に素通しする。
@@ -861,15 +881,30 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
861
881
  return false
862
882
  }
863
883
 
864
- markWheelConsumed(event)
865
- event.preventDefault()
866
- stopInertia()
867
-
868
884
  let deltaY = axes.verticalDelta
869
885
  if (currentWheelSpeedMultiplier !== 1) {
870
886
  deltaY *= currentWheelSpeedMultiplier
871
887
  }
872
888
 
889
+ // ❗ 端の連鎖 (A-2): その向きへ**もう動けない**ホイールは消費せず祖先へ委ねる
890
+ // (ネイティブの `overscroll-behavior: auto` 相当)。判定は倍率適用後の実効 delta の
891
+ // 向き × committed 位置で行う。端に**達する**ノッチはここを素通りして部分消費され
892
+ // (クランプが端で止める)、次のノッチからこの分岐で連鎖する — ネイティブと同じ段付き。
893
+ // `"contain"` は 2.6.0 以前のトラップ挙動 (モーダル内の一覧など背後を動かしたくない
894
+ // 消費側向け) を明示的に維持する。
895
+ if (currentOverscrollBehavior !== "contain") {
896
+ const currentPosition = scrollPositionRef.current
897
+ // contentSize は sizeRef (committed。wheelPolicyRef と同じ layout effect 同期) から読む
898
+ const maxScrollPosition = Math.max(sizeRef.current.contentSize - currentViewportSize, 0)
899
+ if ((deltaY > 0 && currentPosition >= maxScrollPosition) || (deltaY < 0 && currentPosition <= 0)) {
900
+ return false
901
+ }
902
+ }
903
+
904
+ markWheelConsumed(event)
905
+ event.preventDefault()
906
+ stopInertia()
907
+
873
908
  // ホットパスのためログ引数はサンクで遅延評価し、抑制時は DOM 読み取り (scrollTop) も発生させない
874
909
  Logger.debug("[ScrollPane] wheel event", () => ({ deltaY, scrollPosition: scrollPositionRef.current, wheelSpeedMultiplier: currentWheelSpeedMultiplier, deltaMode: event.deltaMode, scrollTop: contentAreaRef.current?.scrollTop }))
875
910
 
@@ -150,6 +150,11 @@ export type VirtualScrollBehaviorOptions = {
150
150
  enableKeyboardNavigation?: boolean
151
151
  wheelSpeedMultiplier?: number
152
152
  inertiaOptions?: ScrollPaneProps["inertiaOptions"]
153
+ /**
154
+ * How wheel input behaves at the list's edges (default: "auto" = chain to ancestors).
155
+ * 一覧の端でホイールがどう振る舞うか (既定: "auto" = 祖先へ連鎖)。`ScrollPaneProps["overscrollBehavior"]` 参照。
156
+ */
157
+ overscrollBehavior?: ScrollPaneProps["overscrollBehavior"]
153
158
  clipItemHeight?: boolean
154
159
  /**
155
160
  * When `true`, the underlying Fenwick tree fully re-samples on every `getItemHeight` change
@@ -914,7 +919,7 @@ const VirtualScrollInner = <T,>(
914
919
  ) => {
915
920
  const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
916
921
 
917
- const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
922
+ const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
918
923
 
919
924
  // viewportSize 未指定のときは ScrollPane が自分の帯を計測し、その値をこのコールバックで通知する。
920
925
  // 描画枚数 (computeRenderingRanges) とアライン計算 (scrollToIndex / ドリフト補正) はこの解決済み値を使う。
@@ -2365,6 +2370,7 @@ const VirtualScrollInner = <T,>(
2365
2370
  background={background}
2366
2371
  tapScrollCircleOptions={tapScrollCircleOptions}
2367
2372
  inertiaOptions={inertiaOptions}
2373
+ overscrollBehavior={overscrollBehavior}
2368
2374
  itemCount={itemCount}
2369
2375
  scrollBarWidth={scrollBarWidth}
2370
2376
  enableThumbDrag={enableThumbDrag}