@aiquants/virtualscroll 2.6.0 → 3.0.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": "3.0.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
@@ -248,15 +253,21 @@ export type VirtualScrollProps<T> = {
248
253
  * `onWheelHorizontal` へ横スクロール量を流すキーボード操作の種別 (既定: 無効)。
249
254
  *
250
255
  * - `[]` / 未指定 (既定): 横キーボードスクロールを行わない。
251
- * - `["shift-arrow"]`: `Shift + ←/→` のみ。木の展開/折りたたみ (`←/→`) やグリッドのセル移動と
252
- * 衝突しないため、既存の行 UI を持つ消費側でも安全に有効化できる。
253
- * - `["arrow"]`: 素の `←/→` のみ。`Shift + ←/→` を選択範囲の拡張に使うグリッド向け。
254
- * - `["arrow", "shift-arrow"]`: 両方。
256
+ * - `["arrow"]`: 素の `←/→` のみ。
255
257
  *
256
- * ❗ **配列なのは 4 状態が独立に必要だからである。** `"none" | "shift-arrows" | "arrows"` のような
257
- * 段階的な文字列にすると `"arrows"` が `"shift-arrows"` を含んでしまい、「素の矢印だけ横スクロール、
258
- * `Shift + ←/→` は消費側の範囲選択に残す」(Excel / データグリッドの標準) が**表現できない**。
259
- * 同じ理由で種別配列を採るのが `pointerDragInputs` であり、本パッケージの既存の作法に揃えてある。
258
+ * ❗ **`Shift + ←/→` は常に消費しない (3.0.0 で `"shift-arrow"` 入力を撤去)。** ブラウザ標準では
259
+ * Shift+矢印は選択範囲の伸縮であり、横取りすると行内テキストを選び直す手段がキーボードから消える。
260
+ * 2.x では「選択があるときだけ譲る」検出 (`getSelection` の交差判定) で共存させていたが、
261
+ * (a) Shadow DOM 内では選択が可搬に検出できない (Chromium の `window.getSelection()` は shadow root
262
+ * 内部を見せず、内部選択は非標準 API でしか取れない)、(b) `Ctrl+A` の全選択では全行が選択と交差して
263
+ * 横キーボードスクロールが選択解除まで全面ロックアウトする、という構造的欠陥が残った。検出の
264
+ * 精度を上げる案はどれも別の正しいユーザー意図を誤判定するため、機能ごと撤去して Shift 側を
265
+ * 無条件でブラウザへ返す。横スクロールの Shift 系入力が必要なら `shift+ホイール` が引き続き使える
266
+ * (`resolveWheelAxes` の軸規則。こちらは選択と衝突しない)。
267
+ *
268
+ * ❗ **語彙が 1 つでも配列なのは意図的である。** 種別配列は `pointerDragInputs` と同じ本パッケージの
269
+ * 作法であり、将来の入力種別追加が型の破壊なしにできる。boolean へ畳むと prop 名が変わり、横軸を
270
+ * `Omit<>` で封じているラッパー (例: `@aiquants/directory-tree`) の封印リストまで連鎖破壊する。
260
271
  *
261
272
  * ❗ **既定が無効なのは、行ハンドラを奪わないためである。** 本パッケージの行キーハンドラは
262
273
  * capture フェーズに付くため、消費側の行 (bubble) より先に走る。既定で `←/→` を消費すると
@@ -289,7 +300,7 @@ export type VirtualScrollProps<T> = {
289
300
  * `Omit<VirtualScrollProps, "onWheelHorizontal">` で横軸を封じている) が `behaviorOptions` を
290
301
  * そのまま素通しするため、封じたはずのシームへ横から到達できてしまう。
291
302
  */
292
- horizontalKeyInputs?: readonly ("arrow" | "shift-arrow")[]
303
+ horizontalKeyInputs?: readonly "arrow"[]
293
304
  /**
294
305
  * Pixels emitted per horizontal arrow key press (default: 40, matching browser arrow scrolling).
295
306
  * 横矢印キー 1 回あたりの移動量 (px。既定 40 = ブラウザの矢印スクロール相当)。
@@ -385,32 +396,6 @@ const ANCHOR_REBASE_DISTANCE = 1_048_576 // 2^20 px
385
396
  */
386
397
  const DEFAULT_HORIZONTAL_KEY_STEP = 40
387
398
 
388
- /**
389
- * Reports whether a non-collapsed text selection currently sits inside the given element.
390
- * 指定要素の中に折り畳まれていないテキスト選択が存在するかを返す処理。
391
- *
392
- * `Shift + ←/→` はブラウザ標準では選択範囲の伸縮である。行の中でテキストを選んでいる最中に
393
- * 横スクロールへ横取りすると、**選び直す手段がキーボードから消える**。選択が空 (キャレットだけ) の
394
- * ときは伸縮の起点が無いため横取りしてよい。
395
- *
396
- * @param element - Element to test the selection against / 選択範囲の所在を調べる要素
397
- * @returns True when a non-collapsed selection is inside the element / 折り畳まれていない選択が内側にある場合に true
398
- */
399
- const hasTextSelectionWithin = (element: HTMLElement): boolean => {
400
- const selection = element.ownerDocument.defaultView?.getSelection()
401
- if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
402
- return false
403
- }
404
- // ❗ 起点 (anchorNode) の包含では不十分。`Ctrl+A` のようにページ全体を選ぶと起点は行の外に落ち、
405
- // 「行の上に見えている選択」を取りこぼす (実ブラウザで実測)。範囲が行と**交差**するかで判定する。
406
- for (let index = 0; index < selection.rangeCount; index += 1) {
407
- if (selection.getRangeAt(index).intersectsNode(element)) {
408
- return true
409
- }
410
- }
411
- return false
412
- }
413
-
414
399
  /**
415
400
  * Converts a numeric size into a non-negative bigint for large collection handling.
416
401
  *
@@ -914,7 +899,7 @@ const VirtualScrollInner = <T,>(
914
899
  ) => {
915
900
  const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
916
901
 
917
- const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
902
+ const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
918
903
 
919
904
  // viewportSize 未指定のときは ScrollPane が自分の帯を計測し、その値をこのコールバックで通知する。
920
905
  // 描画枚数 (computeRenderingRanges) とアライン計算 (scrollToIndex / ドリフト補正) はこの解決済み値を使う。
@@ -1910,14 +1895,14 @@ const VirtualScrollInner = <T,>(
1910
1895
  if (!emitHorizontal) {
1911
1896
  return
1912
1897
  }
1913
- // 押されたジェスチャが許可種別に含まれるかを確認する
1914
- const gesture = event.shiftKey ? "shift-arrow" : "arrow"
1915
- if (!horizontalKeyInputs?.includes(gesture)) {
1898
+ // ❗ Shift 併用は無条件で消費しない (3.0.0 で "shift-arrow" 入力を撤去)。ブラウザ標準では
1899
+ // 選択範囲の伸縮であり、選択の有無での条件分岐はしない 選択検出は Shadow DOM
1900
+ // 可搬に成立せず、Ctrl+A 全選択では全行が交差して解除まで全面ロックアウトするため
1901
+ if (event.shiftKey) {
1916
1902
  return
1917
1903
  }
1918
- // ❗ テキスト選択中の Shift+←/→ は選択範囲の伸縮であり、横スクロールで奪ってはならない
1919
- // (実ブラウザで実測: 奪うと行内のテキストを選び直せなくなる)
1920
- if (event.shiftKey && hasTextSelectionWithin(event.currentTarget)) {
1904
+ // 押されたジェスチャが許可種別に含まれるかを確認する
1905
+ if (!horizontalKeyInputs?.includes("arrow")) {
1921
1906
  return
1922
1907
  }
1923
1908
  // ❗ 不正な移動量は既定へ黙って読み替えず、消費もしない (Strict No-Fallback)。
@@ -2365,6 +2350,7 @@ const VirtualScrollInner = <T,>(
2365
2350
  background={background}
2366
2351
  tapScrollCircleOptions={tapScrollCircleOptions}
2367
2352
  inertiaOptions={inertiaOptions}
2353
+ overscrollBehavior={overscrollBehavior}
2368
2354
  itemCount={itemCount}
2369
2355
  scrollBarWidth={scrollBarWidth}
2370
2356
  enableThumbDrag={enableThumbDrag}