@aiquants/virtualscroll 2.4.0 → 2.5.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.
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
1
+ import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
2
2
  import { Logger } from "./logger.ts"
3
3
  import { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneProps } from "./ScrollPane.tsx"
4
4
  import { useFenwickMapTree } from "./useFenwickMapTree.ts"
@@ -40,11 +40,54 @@ export type VirtualScrollRange = {
40
40
  */
41
41
  export type VirtualScrollHandle = {
42
42
  /**
43
- * Scrolls to a LOGICAL position (updater form receives the current logical position).
43
+ * Jumps to a LOGICAL position (updater form receives the current logical position).
44
44
  * Returns the applied (clamped) LOGICAL position.
45
- * 論理位置へスクロール (updater は現在の論理位置を受け取る)。適用後 (クランプ後) の論理位置を返す。
45
+ * 論理位置へ**ジャンプ**する (updater は現在の論理位置を受け取る)。適用後 (クランプ後) の論理位置を返す。
46
+ *
47
+ * ❗ **連続的なスクロール入力 (ホイール等) の橋渡しには使わないこと。`scrollBy` を使う。**
48
+ * これは「行を狙って飛ぶ」ための API であり、ペイン自身のホイール処理とは意味が 2 点異なる:
49
+ *
50
+ * 1. **絶対位置を `Math.floor` する** (行解決のため)。デルタを足して渡すと 1px 未満の移動が
51
+ * 毎回消えるため、精密トラックパッドでは**一切動かない**。
52
+ * 2. **スクロールアンカーを張る** (`scrollToIndex` 経由)。狙った行に留まり続けるための仕様だが、
53
+ * 以後 `contentSize` / `itemCount` / `viewportSize` / インセットが変わるたびにドリフト補正が
54
+ * その行へ**再ピン留め**するため、絞り込みなどで一覧が過去の位置へ戻って見える。
55
+ *
56
+ * ❗ `scrollTo(handle.getScrollPosition() + delta)` と書かないこと。ペイン未接続時の
57
+ * `getScrollPosition()` は番兵 `-1` を返すため `-1 + delta` が混入する。相対移動は `scrollBy`
58
+ * (推奨) か updater 形式 `scrollTo(prev => prev + delta)` を使う (updater の `prev` は内部の
59
+ * 最新位置から取るため番兵が混入しない)。
46
60
  */
47
61
  scrollTo: (position: number | ((prev: number) => number)) => number
62
+ /**
63
+ * Scrolls by a delta using the exact semantics of the pane's own wheel handling.
64
+ * ペイン自身のホイール処理と**同一の意味**でデルタ分スクロールする。
65
+ *
66
+ * ペインの外に置いた要素 (列ヘッダー帯・固定フッター・横スクロールバー行など) の上での
67
+ * ホイールを一覧へ橋渡しするための API。ホイールリスナーは `.aqvs-scroll-pane` にしか付かないため、
68
+ * ペイン外はホイールの死角になる。この口はその死角を埋める。
69
+ *
70
+ * `scrollTo` との違い (どちらもペイン自身のホイールと同じ扱いにするための選択):
71
+ *
72
+ * - **float のまま相対加算する** (丸めない)。1px 未満のデルタも積み上がって動く。
73
+ * - **アンカーを張らない**。手動スクロールとして扱われるため、保留アンカーはむしろ**解除される**
74
+ * (ペイン上でホイールを回したときと同じ)。以後のサイズ変化で位置が巻き戻ることがない。
75
+ * - 慣性は停止する (ペイン上のホイールと同じ)。
76
+ *
77
+ * @param delta - Signed pixels to scroll (positive scrolls down) / 符号付きの移動量 (正で下方向)
78
+ * @returns The applied (clamped) LOGICAL position / 適用後 (クランプ後) の論理位置
79
+ */
80
+ scrollBy: (delta: number) => number
81
+ /**
82
+ * Applies one wheel event with the pane's own rules; returns whether it was consumed.
83
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す。
84
+ *
85
+ * 一覧の**外**に置いた要素 (列ヘッダー帯・固定フッター・空表示など) のホイールを、ペイン内と
86
+ * 寸分違わぬ意味論で流し込むための唯一の口。軸分解・速度倍率・スクロール可否・横成分の委譲先・
87
+ * 慣性停止のすべてがペイン側の 1 箇所で決まるため、消費側が規則を書き直したり食い違わせたりできない。
88
+ * 直接呼ぶより `useWheelBridge` (passive:false 登録と後始末込み) を使うこと。
89
+ */
90
+ applyWheel: (event: WheelEvent) => boolean
48
91
  /**
49
92
  * Current LOGICAL scroll position. -1 when the pane is not connected.
50
93
  * 現在の論理スクロール位置。ペイン未接続時は -1。
@@ -194,8 +237,71 @@ export type VirtualScrollProps<T> = {
194
237
  onItemFocus?: (index: number) => void
195
238
  scrollBarOptions?: VirtualScrollScrollBarOptions
196
239
  behaviorOptions?: VirtualScrollBehaviorOptions
197
- /** Delegates horizontal wheel/trackpad delta to an upstream owner (e.g. a frozen-column grid). / 横ホイール量を上流へ委譲する。 */
240
+ /** Delegates horizontal wheel/trackpad delta to an upstream owner (e.g. a frozen-column grid). / 横ホイール量を上流へ委譲する。
241
+ *
242
+ * ❗ **ホイール由来だけの口ではない。** `horizontalKeyInputs` を指定すると**キーボード由来の横量**も
243
+ * ここへ流れる。名前は 2.x で公開済みのため据え置くが、意味は「横軸が N px 動いた。横軸はあなたの所有物である」。
244
+ */
198
245
  onWheelHorizontal?: (deltaX: number) => void
246
+ /**
247
+ * Keyboard gestures that emit a horizontal scroll delta through `onWheelHorizontal` (default: none).
248
+ * `onWheelHorizontal` へ横スクロール量を流すキーボード操作の種別 (既定: 無効)。
249
+ *
250
+ * - `[]` / 未指定 (既定): 横キーボードスクロールを行わない。
251
+ * - `["shift-arrow"]`: `Shift + ←/→` のみ。木の展開/折りたたみ (`←/→`) やグリッドのセル移動と
252
+ * 衝突しないため、既存の行 UI を持つ消費側でも安全に有効化できる。
253
+ * - `["arrow"]`: 素の `←/→` のみ。`Shift + ←/→` を選択範囲の拡張に使うグリッド向け。
254
+ * - `["arrow", "shift-arrow"]`: 両方。
255
+ *
256
+ * ❗ **配列なのは 4 状態が独立に必要だからである。** `"none" | "shift-arrows" | "arrows"` のような
257
+ * 段階的な文字列にすると `"arrows"` が `"shift-arrows"` を含んでしまい、「素の矢印だけ横スクロール、
258
+ * `Shift + ←/→` は消費側の範囲選択に残す」(Excel / データグリッドの標準) が**表現できない**。
259
+ * 同じ理由で種別配列を採るのが `pointerDragInputs` であり、本パッケージの既存の作法に揃えてある。
260
+ *
261
+ * ❗ **既定が無効なのは、行ハンドラを奪わないためである。** 本パッケージの行キーハンドラは
262
+ * capture フェーズに付くため、消費側の行 (bubble) より先に走る。既定で `←/→` を消費すると
263
+ * ツリーの展開/折りたたみのような既存操作を奪ってしまう。消費するのは `preventDefault()` のみで
264
+ * **伝播は止めない** (縦の矢印キーと同じ契約)。`stopPropagation()` は行ハンドラだけでなく
265
+ * `document` / `window` の bubble リスナーごとイベントを消し、ホットキーライブラリや
266
+ * キー入力のテレメトリまで巻き添えにするため採らない。消費側が二重動作を避ける手段は
267
+ * `defaultPrevented` の確認である。
268
+ *
269
+ * 前提: 横軸を所有するのは消費側なので、`onWheelHorizontal` が未指定なら何も起きない
270
+ * (キーイベントも消費しない)。行にフォーカスがあるときだけ働くため
271
+ * `behaviorOptions.enableKeyboardNavigation` も必要。
272
+ *
273
+ * ❗ **符号は物理キー基準** (`→` が正、`←` が負) である。横軸の向きを知っているのは消費側だけなので、
274
+ * RTL (`direction: rtl`) の一覧では消費側が受け取った値を反転すること。パッケージ側で
275
+ * `direction` を推測すると、横スクロールの実体 (CSS 変数・`scrollLeft`・transform) がどの要素の
276
+ * どの座標系かを知らないまま符号を決めることになり、当たらない前提を増やすだけになる。
277
+ *
278
+ * ❗ **働くのは行そのものにフォーカスがあるときだけである** (`event.target === event.currentTarget`)。
279
+ * 行の**中**の要素 (自作のタブ・ラジオ相当・スライダー相当など `role` だけで矢印キー操作を実装した
280
+ * ウィジェット、リンク、ネイティブの横スクロール領域) にフォーカスがある間は奪わない。
281
+ * 入力要素の allowlist だけでは `role` 実装のウィジェットを守れないため、対象そのもので判定する。
282
+ *
283
+ * 長押しの連続スクロールは **OS のキーリピート**に委ねる (`keydown` が繰り返し届く)。スクロールバーの
284
+ * 矢印ボタンが明示的なリピートタイマーを持つ (`ARROW_HOLD_DELAY` / `ARROW_HOLD_INTERVAL`) のは
285
+ * ポインタ押下にリピートが存在しないためで、キーボードとの非対称は入力機構の差に由来する。
286
+ *
287
+ * ❗ **`onWheelHorizontal` と同じ「横軸のシーム」に属するため、意図的にトップレベルに置いてある。**
288
+ * `behaviorOptions` の中へ入れると、横軸を自前で所有するラッパー (例: `@aiquants/directory-tree` は
289
+ * `Omit<VirtualScrollProps, "onWheelHorizontal">` で横軸を封じている) が `behaviorOptions` を
290
+ * そのまま素通しするため、封じたはずのシームへ横から到達できてしまう。
291
+ */
292
+ horizontalKeyInputs?: readonly ("arrow" | "shift-arrow")[]
293
+ /**
294
+ * Pixels emitted per horizontal arrow key press (default: 40, matching browser arrow scrolling).
295
+ * 横矢印キー 1 回あたりの移動量 (px。既定 40 = ブラウザの矢印スクロール相当)。
296
+ *
297
+ * ❗ **有限かつ正の値のみを受け付ける。** `0` / 負値 / `NaN` / `Infinity` を渡すと
298
+ * **キーを消費しない** (既定値へ黙って読み替えることはしない)。0 を既定へ差し替えると
299
+ * 呼び出し側の明示的な指定を握り潰し、0 のまま通すと「キーを食うのに 1px も動かない」
300
+ * 死んだ操作になる。
301
+ * 警告は `keydown` ではなく **prop の変化時に 1 回だけ**出す (キーリピート中に
302
+ * コンソールが溢れるため)。
303
+ */
304
+ horizontalKeyStep?: number
199
305
  /**
200
306
  * ARIA / identity attributes applied to the scrollable CONTENT element — the element that
201
307
  * directly owns the rendered rows and excludes the scrollbar and overlay chrome.
@@ -272,6 +378,39 @@ const MAX_RENDERED_ITEMS = 2000
272
378
  */
273
379
  const ANCHOR_REBASE_DISTANCE = 1_048_576 // 2^20 px
274
380
 
381
+ /**
382
+ * Pixels emitted per horizontal arrow key press. Matches the ~40px browsers scroll for an arrow key.
383
+ *
384
+ * 横矢印キー 1 回あたりの移動量。ブラウザが矢印キーでスクロールする約 40px に合わせた既定値。
385
+ */
386
+ const DEFAULT_HORIZONTAL_KEY_STEP = 40
387
+
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
+
275
414
  /**
276
415
  * Converts a numeric size into a non-negative bigint for large collection handling.
277
416
  *
@@ -767,6 +906,8 @@ const VirtualScrollInner = <T,>(
767
906
  scrollBarOptions,
768
907
  behaviorOptions,
769
908
  onWheelHorizontal,
909
+ horizontalKeyInputs,
910
+ horizontalKeyStep = DEFAULT_HORIZONTAL_KEY_STEP,
770
911
  contentProps,
771
912
  }: VirtualScrollProps<T>,
772
913
  ref: React.Ref<VirtualScrollHandle>,
@@ -901,6 +1042,32 @@ const VirtualScrollInner = <T,>(
901
1042
  const previousTopInsetRef = useRef(resolvedInsets.top)
902
1043
  const onScrollRef = useRef<OnScrollCallback | undefined>(onScroll ?? undefined)
903
1044
  const onRangeChangeRef = useRef<OnRangeChangeCallback | undefined>(onRangeChange ?? undefined)
1045
+ // 横委譲コールバックを ref 経由で読む。行キーハンドラ (handleItemKeyDown) は全行の React.memo が
1046
+ // 依存する安定参照でなければならず、コールバックの差し替えで identity を変えられないため。
1047
+ const onWheelHorizontalRef = useRef<((deltaX: number) => void) | undefined>(onWheelHorizontal)
1048
+
1049
+ // ❗ 恒久的に機能しない組み合わせは黙って無視しない。1 回の無効なキーが警告を出すのに
1050
+ // 「機能まるごと死んでいる」が無言なのは筋が通らない (README のリストボックス指南は
1051
+ // enableKeyboardNavigation: false を勧めるため、実際に踏まれる)
1052
+ const hasHorizontalKeyInputs = (horizontalKeyInputs?.length ?? 0) > 0
1053
+ const hasOnWheelHorizontal = Boolean(onWheelHorizontal)
1054
+ useEffect(() => {
1055
+ if (!hasHorizontalKeyInputs) {
1056
+ return
1057
+ }
1058
+ if (!enableKeyboardNavigation) {
1059
+ Logger.warn("[VirtualScroll] horizontalKeyInputs is set but behaviorOptions.enableKeyboardNavigation is false; horizontal keyboard scrolling is disabled because the row key handler is not mounted.")
1060
+ }
1061
+ if (!hasOnWheelHorizontal) {
1062
+ Logger.warn("[VirtualScroll] horizontalKeyInputs is set but onWheelHorizontal is missing; horizontal keyboard scrolling is disabled because the package does not own the horizontal axis.")
1063
+ }
1064
+ // ❗ 移動量の検証もここで行う。keydown 側で警告するとキーリピート中に毎フレーム出力される
1065
+ // (Logger の既定水準は WARN なので本番でも出る)
1066
+ if (!(Number.isFinite(horizontalKeyStep) && horizontalKeyStep > 0)) {
1067
+ Logger.warn(`[VirtualScroll] horizontalKeyStep must be a finite positive number, received ${horizontalKeyStep}. Horizontal arrow keys are left untouched.`)
1068
+ }
1069
+ }, [hasHorizontalKeyInputs, hasOnWheelHorizontal, enableKeyboardNavigation, horizontalKeyStep])
1070
+
904
1071
  const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map())
905
1072
  const pendingFocusIndexRef = useRef<number | null>(null)
906
1073
  const lastFocusedIndexRef = useRef<number | null>(null)
@@ -921,12 +1088,15 @@ const VirtualScrollInner = <T,>(
921
1088
  isResizingRef.current = true
922
1089
  }
923
1090
 
924
- useEffect(() => {
1091
+ // ❗ useEffect ではなく useLayoutEffect である。差し替え直後の keydown / wheel が
1092
+ // **1 フレーム古いコールバック**を読む窓を消すため (通常の effect はペイントを跨いで遅延しうる)
1093
+ useLayoutEffect(() => {
925
1094
  // 目的: 外部から渡されたコールバックの参照を最新状態に保つ。
926
- // 依存関係: onRangeChange, onScroll
1095
+ // 依存関係: onRangeChange, onScroll, onWheelHorizontal
927
1096
  onScrollRef.current = onScroll ?? undefined
928
1097
  onRangeChangeRef.current = onRangeChange ?? undefined
929
- }, [onRangeChange, onScroll])
1098
+ onWheelHorizontalRef.current = onWheelHorizontal
1099
+ }, [onRangeChange, onScroll, onWheelHorizontal])
930
1100
 
931
1101
  const tryFocusElement = useCallback(
932
1102
  (element: HTMLElement | null) => {
@@ -1518,6 +1688,43 @@ const VirtualScrollInner = <T,>(
1518
1688
  [resolvedInsets.top, scrollTo, updateScrollPositionImmediate],
1519
1689
  )
1520
1690
 
1691
+ /**
1692
+ * Applies a delta through the pane's own wheel seam (float accumulation, no anchor).
1693
+ *
1694
+ * ペイン自身のホイールと同じ口 (float 相対加算・アンカー不変) でデルタを適用する処理。
1695
+ *
1696
+ * ❗ **`scrollTo` を経由してはならない。** あちらは行解決のため絶対位置を `Math.floor` し、
1697
+ * `scrollToIndex` 経由で保留アンカーを張る。デルタの橋渡しに使うと (1) 1px 未満の移動が毎回
1698
+ * 消えて精密トラックパッドで動かず、(2) 以後のサイズ変化のたびにドリフト補正が過去の行へ
1699
+ * 再ピン留めして一覧が勝手に戻る。ここでは `ScrollPaneHandle.scrollTo` の updater 形式へ
1700
+ * そのまま流し、ペイン内のホイール (`scrollTo(prev => prev + deltaY)`) と**同一の経路**を通す。
1701
+ *
1702
+ * 位置の反映は `onScroll` → `handleScroll` が行う (ペイン上のホイールと完全に同じ)。ここで
1703
+ * 追加の `updateScrollPositionImmediate` を呼ばないのは、経路を 1 本に保つためである。
1704
+ * 非有限デルタのガードとクランプは `ScrollPane.scrollTo` が単一の防波堤として担う。
1705
+ */
1706
+ const scrollBy = useCallback((delta: number): number => {
1707
+ const pane = scrollPaneRef.current
1708
+ if (!pane) {
1709
+ // ペイン未接続時は動かしようがない。番兵 (-1) は返さず現在の論理位置をそのまま返す
1710
+ return toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsetsTopRef.current)
1711
+ }
1712
+ const appliedPanePosition = pane.scrollTo((previous) => previous + delta)
1713
+ return toLogicalPositionWithInset(appliedPanePosition, resolvedInsetsTopRef.current)
1714
+ }, [])
1715
+
1716
+ /**
1717
+ * Applies one wheel event with the pane's own rules; returns whether it was consumed.
1718
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す処理。
1719
+ *
1720
+ * ペイン (`ScrollPane`) が持つ 1 つの適用関数へそのまま委譲する。ここで規則を書き直さないことが
1721
+ * 「ペイン内と帯の上で挙動が食い違わない」ことの根拠である。
1722
+ *
1723
+ * @param event - The wheel event to apply / 適用するホイールイベント
1724
+ * @returns True when the event was consumed / 消費した場合に true
1725
+ */
1726
+ const applyWheel = useCallback((event: WheelEvent): boolean => scrollPaneRef.current?.applyWheel(event) ?? false, [])
1727
+
1521
1728
  /**
1522
1729
  * Handles scroll events from the pane.
1523
1730
  *
@@ -1653,15 +1860,50 @@ const VirtualScrollInner = <T,>(
1653
1860
  if (event.altKey || event.metaKey || event.ctrlKey) {
1654
1861
  return
1655
1862
  }
1656
- const target = event.target as HTMLElement | null
1657
- if (target) {
1658
- const tagName = target.tagName
1659
- if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") {
1863
+ // **行そのものにフォーカスがあるときだけ働く。** ハンドラは capture フェーズに付くため、
1864
+ // 行の中の要素にフォーカスがある状態で消費すると、その要素へ keydown が
1865
+ // **どちらのフェーズでも一切届かなくなる** (実ブラウザで実測)。入力要素の allowlist だけでは
1866
+ // `role` で実装したスライダー相当・タブ相当や、ネイティブの縦横スクロール領域を守れない。
1867
+ //
1868
+ // ❗ 縦横の**両方**に適用する。横だけに掛けると、同じウィジェットが `←→` は受け取れるのに
1869
+ // `↑↓` は奪われたうえフォーカスまで隣の行へ飛ばされる、という一貫性の無い状態になる。
1870
+ if (event.target !== event.currentTarget) {
1871
+ return
1872
+ }
1873
+ // ❗ 行ラッパー自身が編集可能な文脈にある場合はキーを奪わない。上の判定で
1874
+ // `target === currentTarget` (常に本パッケージの `div`) が確定しているため、
1875
+ // `INPUT` / `TEXTAREA` / `SELECT` のタグ判定はここでは**到達不能**であり撤去した
1876
+ // (それらは行の中の要素であり、上の判定で既に見送られる)。
1877
+ // 一方 `isContentEditable` は**祖先からの継承**で true になりうるため残す
1878
+ // (消費側が一覧を編集可能な領域で囲むケース)。
1879
+ if (event.currentTarget.isContentEditable) {
1880
+ return
1881
+ }
1882
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
1883
+ // 横軸を所有するのは消費側なので、委譲先が無ければキーイベントを消費しない
1884
+ // (ツリーの展開/折りたたみなど行側の操作をそのまま通す)。
1885
+ const emitHorizontal = onWheelHorizontalRef.current
1886
+ if (!emitHorizontal) {
1660
1887
  return
1661
1888
  }
1662
- if (target.isContentEditable) {
1889
+ // 押されたジェスチャが許可種別に含まれるかを確認する
1890
+ const gesture = event.shiftKey ? "shift-arrow" : "arrow"
1891
+ if (!horizontalKeyInputs?.includes(gesture)) {
1663
1892
  return
1664
1893
  }
1894
+ // ❗ テキスト選択中の Shift+←/→ は選択範囲の伸縮であり、横スクロールで奪ってはならない
1895
+ // (実ブラウザで実測: 奪うと行内のテキストを選び直せなくなる)
1896
+ if (event.shiftKey && hasTextSelectionWithin(event.currentTarget)) {
1897
+ return
1898
+ }
1899
+ // ❗ 不正な移動量は既定へ黙って読み替えず、消費もしない (Strict No-Fallback)。
1900
+ // 警告は上の effect が prop 変化のたびに 1 回だけ出す (キーリピートで溢れさせない)
1901
+ if (!(Number.isFinite(horizontalKeyStep) && horizontalKeyStep > 0)) {
1902
+ return
1903
+ }
1904
+ event.preventDefault()
1905
+ emitHorizontal(event.key === "ArrowLeft" ? -horizontalKeyStep : horizontalKeyStep)
1906
+ return
1665
1907
  }
1666
1908
  if (event.key === "ArrowDown") {
1667
1909
  if (index < itemCount - 1) {
@@ -1702,7 +1944,7 @@ const VirtualScrollInner = <T,>(
1702
1944
  }
1703
1945
  }
1704
1946
  },
1705
- [enableKeyboardNavigation, itemCount, focusItemAtIndex],
1947
+ [enableKeyboardNavigation, itemCount, focusItemAtIndex, horizontalKeyInputs, horizontalKeyStep],
1706
1948
  )
1707
1949
 
1708
1950
  const handleItemFocus = useCallback(
@@ -2072,6 +2314,8 @@ const VirtualScrollInner = <T,>(
2072
2314
  getContentSize: () => scrollPaneRef.current?.getContentSize() ?? -1,
2073
2315
  getViewportSize: () => scrollPaneRef.current?.getViewportSize() ?? -1,
2074
2316
  scrollTo: scrollToHandle,
2317
+ scrollBy,
2318
+ applyWheel,
2075
2319
  scrollToIndex,
2076
2320
  getFenwickTreeTotalHeight: () => fenwickTree.getTotal(),
2077
2321
  getFenwickSize: () => fenwickTree.getSize(),
@@ -2080,7 +2324,7 @@ const VirtualScrollInner = <T,>(
2080
2324
  getScrollAnchor,
2081
2325
  updateItemSize,
2082
2326
  }),
2083
- [scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
2327
+ [scrollToHandle, scrollBy, applyWheel, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
2084
2328
  )
2085
2329
 
2086
2330
  const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export { tapScrollCircleSampleVisual } from "./tapScrollCircleSampleVisual.tsx"
12
12
  export { FenwickMapTree, useFenwickMapTree } from "./useFenwickMapTree.ts"
13
13
  export { useHeightCache } from "./useHeightCache.ts"
14
14
  export { useLruCache } from "./useLruCache.ts"
15
+ export { useWheelBridge, type WheelBridgeOptions, type WheelBridgeTarget } from "./useWheelBridge.ts"
15
16
  export { minmax } from "./utils.ts"
16
17
  export {
17
18
  VirtualScroll,
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @module useWheelBridge
3
+ * @description Gives elements placed OUTSIDE the scroll pane the pane's own wheel semantics.
4
+ *
5
+ * @description スクロールペインの**外**に置いた要素へ、ペイン自身のホイール意味論を与えるフック。
6
+ *
7
+ * ペインのホイールリスナーは `.aqvs-scroll-pane` にしか付かないため、その外に置いた要素
8
+ * (列ヘッダー帯・固定フッター・横スクロールバー行・空表示など) はホイールの死角になる。明細表では
9
+ * 列ヘッダーを仮想スクロールの外に固定するのが定石なので、この死角は必ず生じる。
10
+ */
11
+
12
+ import { type RefCallback, type RefObject, useCallback, useLayoutEffect, useRef } from "react"
13
+
14
+ /**
15
+ * Anything that can apply a wheel event with the pane's own rules.
16
+ * ペイン自身の規則でホイールイベントを適用できるもの。
17
+ *
18
+ * `VirtualScrollHandle` と `ScrollPaneHandle` の両方が構造的にこれを満たすため、どちらのハンドルでも
19
+ * 同じフックが使える (`ScrollPane` を直接使う消費側が橋渡しを自作せずに済む)。
20
+ */
21
+ export type WheelBridgeTarget = {
22
+ applyWheel: (event: WheelEvent) => boolean
23
+ }
24
+
25
+ /**
26
+ * Options for {@link useWheelBridge}.
27
+ * {@link useWheelBridge} のオプション。
28
+ */
29
+ export type WheelBridgeOptions = {
30
+ /**
31
+ * Set to `false` to detach the bridge without unmounting the element.
32
+ * 要素をアンマウントせずに橋渡しを止めたいとき `false` にする。既定 `true`。
33
+ */
34
+ enableBridge?: boolean
35
+ }
36
+
37
+ /**
38
+ * Bridges wheel events from an element outside the pane into the list, with the pane's own semantics.
39
+ * ペイン外の要素のホイールを、ペイン自身と同じ意味論で一覧へ橋渡しするフック。
40
+ *
41
+ * 返り値を対象要素の `ref` に渡すだけでよい。
42
+ *
43
+ * ```tsx
44
+ * const listRef = useRef<VirtualScrollHandle>(null)
45
+ * const headerRef = useWheelBridge(listRef)
46
+ *
47
+ * <div ref={headerRef}>列ヘッダー (ペインの外)</div>
48
+ * <VirtualScroll ref={listRef} onWheelHorizontal={setScrollX} ... />
49
+ * ```
50
+ *
51
+ * ❗ **ホイールの意味論に影響する設定は一切受け取らないのが意図的な設計である。** 速度倍率・
52
+ * 横成分の委譲先・スクロール可否・慣性停止・軸分解のすべては、ハンドルの向こう側 (`applyWheel`) が
53
+ * 持つ。橋渡し側にも同じ設定口を用意すると同じグリッドなのに「ヘッダー帯の上だけスクロールが遅い」
54
+ * 「帯の上でだけ横に動かない」といった食い違いが静かに生まれる (橋渡し側にも設定口を置いた
55
+ * 実装途中の版で実測: `wheelSpeedMultiplier: 3` のとき同じ 1 ノッチがペイン上 30px / 帯の上 10px)。
56
+ * 唯一のオプション {@link WheelBridgeOptions.enableBridge} は意味論ではなく**橋渡し自体の ON/OFF** である。
57
+ * このフックの責務は **`{ passive: false }` での登録・後始末・その ON/OFF だけ**である。
58
+ *
59
+ * ❗ **`onWheel` プロップでは代用できない。** React 19 は `onWheel` を passive リスナーとして登録するため
60
+ * `preventDefault()` が効かず、ページ全体がスクロールしてしまう。
61
+ *
62
+ * ❗ **`handle.scrollTo` で自前に橋渡ししてはならない。** あれはジャンプ用 API で、絶対位置を丸めるため
63
+ * 1px 未満のデルタが消え、さらにスクロールアンカーを張るためサイズ変化のたびに一覧が過去の位置へ戻る。
64
+ *
65
+ * 自前の ref も必要なら合成してよい (多重登録に対して安全に作ってある)。
66
+ * ❗ **戻り値をそのまま `return` すること。** React 19 はこれをクリーンアップとして実行する。
67
+ * 返さない場合も動作は正しいままだが、`null` 呼び出しでは何も外さない設計のため、リスナーは
68
+ * 要素ごと GC されるまで残る。
69
+ *
70
+ * ```tsx
71
+ * <div ref={(node) => { myRef.current = node; return headerRef(node) }} />
72
+ * ```
73
+ *
74
+ * @param target - Ref to the list's imperative handle (`VirtualScrollHandle` / `ScrollPaneHandle`) / 一覧の命令ハンドルへの ref
75
+ * @param options - Bridge options / 橋渡しのオプション
76
+ * @returns A ref callback to attach to the source element / 発生元の要素へ渡す ref コールバック
77
+ */
78
+ export const useWheelBridge = (target: RefObject<WheelBridgeTarget | null>, options?: WheelBridgeOptions): RefCallback<HTMLElement> => {
79
+ // オプションは ref 経由で読む。ref コールバックの identity を安定させ、
80
+ // 設定の差し替えのたびにリスナーを張り直さないため。
81
+ const optionsRef = useRef(options)
82
+ // ❗ 対象ハンドルも ref 越しに読む。`target` を依存に置くと、消費側が別の一覧へ差し替えたときに
83
+ // ref コールバックの identity が変わり、React が `null` → `node` と呼び直す。台帳は要素単位で
84
+ // 「既に張ってある」と判断して**張り直さない**ため、古い listener が古い一覧を掴んだままになる
85
+ // (実測: 差し替え後の一覧が一切動かない)。identity を固定すれば張り直し自体が不要になる。
86
+ const targetRef = useRef(target)
87
+ useLayoutEffect(() => {
88
+ optionsRef.current = options
89
+ targetRef.current = target
90
+ }, [options, target])
91
+
92
+ // リスナーを張った要素の台帳。**要素ごと**に持つ。単一スロットにすると、ある要素の
93
+ // ref が呼び直されただけで**別の要素**のリスナーを剥がしてしまう (実測: ヘッダー帯と
94
+ // フッター帯で同じフックを使い回すと、最初の再レンダーで生きている帯が入れ替わる)。
95
+ // WeakMap なので、外し損ねた要素があっても要素ごと GC される。
96
+ const attachedRef = useRef<WeakMap<HTMLElement, (event: WheelEvent) => void>>(new WeakMap())
97
+
98
+ return useCallback((node: HTMLElement | null) => {
99
+ // ❗ null 呼び出しでは何も外さない。どの要素の話か分からないため、ここで外すと
100
+ // 無関係の要素を巻き添えにする。解除は下のクリーンアップ (要素を知っている) が行う。
101
+ if (!node) {
102
+ return
103
+ }
104
+ // ❗ 既に張ってあれば何もしない。React 19 のクリーンアップを**受け取らない**合成 ref
105
+ // (`ref={(n) => { myRef.current = n; headerRef(n) }}`) では React は毎レンダーで
106
+ // null → node と呼び直すため、冪等でないとリスナーが積み上がる (再レンダー 4 回で 5 本)。
107
+ // ❗ 直ちに N 倍スクロールするわけではない (`applyWheel` 内の「消費済み印」が 2 本目以降を
108
+ // 弾く)。実害はリスナーの増殖そのもの = ホイールのたびに N 個のハンドラが起動する
109
+ // ホットパスの劣化であり、位置の比較では検出できない。だから本数で固定してある。
110
+ if (attachedRef.current.has(node)) {
111
+ return
112
+ }
113
+
114
+ const listener = (event: WheelEvent) => {
115
+ if (optionsRef.current?.enableBridge === false) {
116
+ return
117
+ }
118
+ // ❗ 二重適用の防止は `applyWheel` の内側が持つ (「自分が消費した印」を見る)。
119
+ // ここで `defaultPrevented` を見てはならない。ペイン本体はそれを見ないため、
120
+ // ページ全体のスクロールロック中に「ペインは動くのに帯の上だけ死ぬ」という
121
+ // 食い違いが生まれる (実測)。
122
+ targetRef.current.current?.applyWheel(event)
123
+ }
124
+
125
+ // ❗ passive: false でなければ preventDefault が効かない (React の onWheel は passive)
126
+ node.addEventListener("wheel", listener, { passive: false })
127
+ attachedRef.current.set(node, listener)
128
+
129
+ // React 19 はこのクリーンアップを受け取ると `null` 呼び出しの代わりに実行する。
130
+ // ❗ listener の一致判定は**意図的な防御**である。ref コールバックの identity は固定
131
+ // (依存配列が空) なので、要素がマウントされている限り React は張り直さず、現状この分岐が
132
+ // 偽になる経路は無い。依存配列を増やした瞬間に「古いクリーンアップが新しい登録を消す」
133
+ // 事故が起きるため、ブラックボックステストで踏めなくても残す。
134
+ return () => {
135
+ if (attachedRef.current.get(node) === listener) {
136
+ node.removeEventListener("wheel", listener)
137
+ attachedRef.current.delete(node)
138
+ }
139
+ }
140
+ }, [])
141
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @module wheelAxes
3
+ * @description Single source of truth for how a wheel event is split into scroll axes.
4
+ * Used only by `ScrollPane.applyWheel`, which is itself the single seam every wheel caller goes
5
+ * through, so the rules cannot drift between the pane and elements outside it.
6
+ *
7
+ * @description ホイールイベントをスクロール軸へ分解する規則の単一の真実。呼び出し元は
8
+ * `ScrollPane.applyWheel` **だけ**であり、その `applyWheel` がすべてのホイール呼び出し元
9
+ * (ペイン根のリスナー / `useWheelBridge` / 消費側の直接呼び出し) の唯一のシームなので、
10
+ * ペイン内外で規則が食い違わない。
11
+ *
12
+ * ❗ **公開 API ではない** (`index.ts` から export しない)。公開すると `LINE_HEIGHT_PX = 16`・
13
+ * 同値時の横倒し・4 フィールドの形が恒久的に凍結される。
14
+ */
15
+
16
+ /** Line height (px) assumed for `DOM_DELTA_LINE`. / `DOM_DELTA_LINE` で仮定する 1 行の高さ (px)。 */
17
+ const LINE_HEIGHT_PX = 16
18
+
19
+ /**
20
+ * Wheel axes resolved from a raw wheel event.
21
+ * 生のホイールイベントから解決した軸情報。
22
+ */
23
+ export type ResolvedWheelAxes = {
24
+ /** True when ctrl is held, i.e. the browser treats it as zoom. Deltas are still reported as-is. / ctrl 押下 (ブラウザがズームとして扱う) の場合に true。デルタはそのまま報告する。 */
25
+ isZoomGesture: boolean
26
+ /** True when the horizontal component dominates (includes shift+wheel). / 横成分が優勢な場合に true (shift+ホイールを含む)。 */
27
+ isHorizontalDominant: boolean
28
+ /** Horizontal delta in px (deltaMode already converted). / px 単位の横方向デルタ (deltaMode 変換済み)。 */
29
+ horizontalDelta: number
30
+ /** Vertical delta in px (deltaMode already converted). / px 単位の縦方向デルタ (deltaMode 変換済み)。 */
31
+ verticalDelta: number
32
+ }
33
+
34
+ /**
35
+ * Converts a raw delta to pixels according to the event's `deltaMode`.
36
+ * イベントの `deltaMode` に従って生のデルタを px へ変換する処理。
37
+ *
38
+ * @param delta - Raw delta from the event / イベントの生デルタ
39
+ * @param deltaMode - `WheelEvent.deltaMode` / `WheelEvent.deltaMode`
40
+ * @param viewportSize - Viewport length used for `DOM_DELTA_PAGE` / `DOM_DELTA_PAGE` で使うビューポート長
41
+ * @returns The delta in pixels / px 単位のデルタ
42
+ */
43
+ const toPixelDelta = (delta: number, deltaMode: number, viewportSize: number): number => {
44
+ if (deltaMode === 1) {
45
+ // DOM_DELTA_LINE: 行単位
46
+ return delta * LINE_HEIGHT_PX
47
+ }
48
+ if (deltaMode === 2) {
49
+ // DOM_DELTA_PAGE: ページ単位
50
+ return delta * viewportSize
51
+ }
52
+ return delta
53
+ }
54
+
55
+ /**
56
+ * Splits a wheel event into scroll axes, in pixels, applying the package's gesture rules.
57
+ * ホイールイベントを px 単位の軸へ分解し、本パッケージのジェスチャ規則を適用する処理。
58
+ *
59
+ * 規則 (判定順序そのものが仕様):
60
+ *
61
+ * 1. **shift+ホイール**は `deltaX === 0` のとき縦量を横として読む (Windows 系の慣習)。ブラウザが既に
62
+ * `deltaX` へマップ済みなら通常の軸のまま扱う。
63
+ * 2. **横優勢判定**は横成分と縦成分の絶対値比較。等しい場合 (`|h| === |v|`) は横に倒す。ただし
64
+ * **両軸とも 0 のときは横優勢としない** — 慣性の惰性通知で毎 tick 送られる 0/0 を横として
65
+ * 消費すると、ページ側のスクロールを無言で飲み込む。
66
+ *
67
+ * ❗ **これは解決器であって方針ではない。** `isZoomGesture` (ctrl 押下) が true でもデルタは
68
+ * そのまま返す。「ズームは横取りしない」という**方針**は適用側 (`ScrollPane.applyWheel`) が持つ。
69
+ *
70
+ * @param event - The wheel event / ホイールイベント
71
+ * @param viewportSize - Viewport length for `DOM_DELTA_PAGE` conversion / `DOM_DELTA_PAGE` 変換に使うビューポート長
72
+ * @returns The resolved axes / 解決した軸情報
73
+ */
74
+ export const resolveWheelAxes = (event: WheelEvent, viewportSize: number): ResolvedWheelAxes => {
75
+ const usesShiftAxis = event.shiftKey && event.deltaX === 0
76
+ const rawHorizontal = usesShiftAxis ? event.deltaY : event.deltaX
77
+ const rawVertical = usesShiftAxis ? 0 : event.deltaY
78
+ const isHorizontalDominant = rawHorizontal !== 0 && Math.abs(rawHorizontal) >= Math.abs(rawVertical)
79
+
80
+ return {
81
+ // ❗ ズームでもデルタは潰さない。ここは**事実だけ**を報告し、「横取りしない」という
82
+ // 判断は適用側 (applyWheel) が下す。ここで 0 を詰めると適用側の ctrl 判定が
83
+ // 到達不能な飾りになり、`ctrl+shift+ホイール` を横スクロールとして食う退行を
84
+ // どのテストも検出できなくなる (変異解析で実証済み)。
85
+ isZoomGesture: event.ctrlKey,
86
+ isHorizontalDominant,
87
+ horizontalDelta: toPixelDelta(rawHorizontal, event.deltaMode, viewportSize),
88
+ verticalDelta: toPixelDelta(rawVertical, event.deltaMode, viewportSize),
89
+ }
90
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @module wheelConsumption
3
+ * @description Tracks which wheel events this package has already applied.
4
+ *
5
+ * @description 本パッケージが既に適用したホイールイベントを記録するモジュール。
6
+ *
7
+ * ❗ **`event.defaultPrevented` では代用できない。** あれは「誰かがこのイベントを止めた」であって
8
+ * 「**我々が**消費した」ではない。両者を混同すると、ページ全体のスクロールロック
9
+ * (`document` に capture + `preventDefault` を張る定番の実装) を掛けただけで一覧が
10
+ * まったくスクロールできなくなる。逆に印が無いと、ペインの入れ子や祖先に張った `useWheelBridge` で
11
+ * **同じデルタが 2 回適用**される (実測: 内側と外側の一覧が 1 ノッチで両方 100px 動く)。
12
+ *
13
+ * `WeakSet` なのでイベントオブジェクトの寿命を延ばさない。
14
+ */
15
+
16
+ /** Wheel events already applied by a pane in this page. / このページのペインが適用済みのホイールイベント。 */
17
+ const consumedWheelEvents = new WeakSet<WheelEvent>()
18
+
19
+ /**
20
+ * Marks a wheel event as applied by this package.
21
+ * ホイールイベントを「本パッケージが適用済み」として記録する処理。
22
+ *
23
+ * @param event - The wheel event that was applied / 適用したホイールイベント
24
+ */
25
+ export const markWheelConsumed = (event: WheelEvent): void => {
26
+ consumedWheelEvents.add(event)
27
+ }
28
+
29
+ /**
30
+ * Reports whether this package already applied the given wheel event.
31
+ * 指定のホイールイベントを本パッケージが既に適用済みかどうかを返す処理。
32
+ *
33
+ * @param event - The wheel event to test / 判定するホイールイベント
34
+ * @returns True when it was already applied / 既に適用済みの場合に true
35
+ */
36
+ export const wasWheelConsumed = (event: WheelEvent): boolean => consumedWheelEvents.has(event)