@aiquants/virtualscroll 2.3.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。
@@ -151,7 +194,21 @@ export type VirtualScrollProps<T> = {
151
194
  * さもないとキャッシュ済み高さが別のアイテムを指すインデックスに残る。
152
195
  */
153
196
  getItemHeight: (index: number) => number
154
- viewportSize: number
197
+ /**
198
+ * Size of the visible band (px). Omit it to let the component measure its own band.
199
+ * 可視帯の高さ (px)。省略するとコンポーネントが自分の帯を計測する。
200
+ *
201
+ * ❗ **省略が既定の使い方である。** この値は帯のクリップ窓・最大スクロール位置・つまみ写像・
202
+ * 描画枚数・スクロール可否のすべての基準であり、実際の帯と食い違うと 5 通りの壊れ方が
203
+ * **例外も警告も出さずに**発生する。自分で測れるものを消費側に渡させると、同じ正解を各画面で
204
+ * 書き直すことになり、実際に推測値や定数が代入される (詳細は `ScrollPaneProps.viewportSize`)。
205
+ *
206
+ * 省略時はホスト要素が高さを確定させていること (`h-full` / `flex-1` + `min-h-0` / 明示 px など)
207
+ * が前提で、高さが auto のホストでは帯が 0 のままになる (`Logger.warn` で警告する)。
208
+ * 明示する正当なケースは「帯の高さを計測ではなく算出で知っている」場合のみである
209
+ * (例: ドロップダウンが件数 × 行高から高さを決める)。
210
+ */
211
+ viewportSize?: number
155
212
  overscanCount?: number
156
213
  className?: string
157
214
  /** Test id emitted as data-testid on the scroll root (DOM hooks must use data-* attributes, never class selectors). / スクロールルートに data-testid として出力されるテスト ID (DOM フックはクラスセレクタでなく data-* 属性を使う)。 */
@@ -180,8 +237,71 @@ export type VirtualScrollProps<T> = {
180
237
  onItemFocus?: (index: number) => void
181
238
  scrollBarOptions?: VirtualScrollScrollBarOptions
182
239
  behaviorOptions?: VirtualScrollBehaviorOptions
183
- /** 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
+ */
184
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
185
305
  /**
186
306
  * ARIA / identity attributes applied to the scrollable CONTENT element — the element that
187
307
  * directly owns the rendered rows and excludes the scrollbar and overlay chrome.
@@ -258,6 +378,39 @@ const MAX_RENDERED_ITEMS = 2000
258
378
  */
259
379
  const ANCHOR_REBASE_DISTANCE = 1_048_576 // 2^20 px
260
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
+
261
414
  /**
262
415
  * Converts a numeric size into a non-negative bigint for large collection handling.
263
416
  *
@@ -736,7 +889,7 @@ const VirtualScrollInner = <T,>(
736
889
  getItem,
737
890
  getItemKey,
738
891
  getItemHeight,
739
- viewportSize,
892
+ viewportSize: viewportSizeProp,
740
893
  overscanCount = 15,
741
894
  className,
742
895
  testId,
@@ -753,6 +906,8 @@ const VirtualScrollInner = <T,>(
753
906
  scrollBarOptions,
754
907
  behaviorOptions,
755
908
  onWheelHorizontal,
909
+ horizontalKeyInputs,
910
+ horizontalKeyStep = DEFAULT_HORIZONTAL_KEY_STEP,
756
911
  contentProps,
757
912
  }: VirtualScrollProps<T>,
758
913
  ref: React.Ref<VirtualScrollHandle>,
@@ -761,6 +916,16 @@ const VirtualScrollInner = <T,>(
761
916
 
762
917
  const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
763
918
 
919
+ // viewportSize 未指定のときは ScrollPane が自分の帯を計測し、その値をこのコールバックで通知する。
920
+ // 描画枚数 (computeRenderingRanges) とアライン計算 (scrollToIndex / ドリフト補正) はこの解決済み値を使う。
921
+ // 初回コミットは 0 だが ScrollPane が paint 前 (layout effect) に通知するためちらつきは無く、
922
+ // 帯が landed した時点でドリフト補正 effect (viewportSize を依存に持つ) がアンカー位置を再計算する。
923
+ const [measuredViewportSize, setMeasuredViewportSize] = useState(0)
924
+ const viewportSize = viewportSizeProp ?? measuredViewportSize
925
+ const handleViewportSizeChange = useCallback((nextViewportSize: number) => {
926
+ setMeasuredViewportSize((previous) => (previous === nextViewportSize ? previous : nextViewportSize))
927
+ }, [])
928
+
764
929
  // ❗ 内部ペイン ref の型は ScrollPaneHandle (ペイン座標)。VirtualScrollHandle と誤記すると
765
930
  // 2.0.0 以降は型ドキュメントが「論理座標を返す」と嘘をつく (構造的互換で型検査は通ってしまう)
766
931
  const scrollPaneRef = useRef<ScrollPaneHandle>(null)
@@ -877,6 +1042,32 @@ const VirtualScrollInner = <T,>(
877
1042
  const previousTopInsetRef = useRef(resolvedInsets.top)
878
1043
  const onScrollRef = useRef<OnScrollCallback | undefined>(onScroll ?? undefined)
879
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
+
880
1071
  const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map())
881
1072
  const pendingFocusIndexRef = useRef<number | null>(null)
882
1073
  const lastFocusedIndexRef = useRef<number | null>(null)
@@ -897,12 +1088,15 @@ const VirtualScrollInner = <T,>(
897
1088
  isResizingRef.current = true
898
1089
  }
899
1090
 
900
- useEffect(() => {
1091
+ // ❗ useEffect ではなく useLayoutEffect である。差し替え直後の keydown / wheel が
1092
+ // **1 フレーム古いコールバック**を読む窓を消すため (通常の effect はペイントを跨いで遅延しうる)
1093
+ useLayoutEffect(() => {
901
1094
  // 目的: 外部から渡されたコールバックの参照を最新状態に保つ。
902
- // 依存関係: onRangeChange, onScroll
1095
+ // 依存関係: onRangeChange, onScroll, onWheelHorizontal
903
1096
  onScrollRef.current = onScroll ?? undefined
904
1097
  onRangeChangeRef.current = onRangeChange ?? undefined
905
- }, [onRangeChange, onScroll])
1098
+ onWheelHorizontalRef.current = onWheelHorizontal
1099
+ }, [onRangeChange, onScroll, onWheelHorizontal])
906
1100
 
907
1101
  const tryFocusElement = useCallback(
908
1102
  (element: HTMLElement | null) => {
@@ -1494,6 +1688,43 @@ const VirtualScrollInner = <T,>(
1494
1688
  [resolvedInsets.top, scrollTo, updateScrollPositionImmediate],
1495
1689
  )
1496
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
+
1497
1728
  /**
1498
1729
  * Handles scroll events from the pane.
1499
1730
  *
@@ -1629,15 +1860,50 @@ const VirtualScrollInner = <T,>(
1629
1860
  if (event.altKey || event.metaKey || event.ctrlKey) {
1630
1861
  return
1631
1862
  }
1632
- const target = event.target as HTMLElement | null
1633
- if (target) {
1634
- const tagName = target.tagName
1635
- 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) {
1636
1887
  return
1637
1888
  }
1638
- if (target.isContentEditable) {
1889
+ // 押されたジェスチャが許可種別に含まれるかを確認する
1890
+ const gesture = event.shiftKey ? "shift-arrow" : "arrow"
1891
+ if (!horizontalKeyInputs?.includes(gesture)) {
1639
1892
  return
1640
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
1641
1907
  }
1642
1908
  if (event.key === "ArrowDown") {
1643
1909
  if (index < itemCount - 1) {
@@ -1678,7 +1944,7 @@ const VirtualScrollInner = <T,>(
1678
1944
  }
1679
1945
  }
1680
1946
  },
1681
- [enableKeyboardNavigation, itemCount, focusItemAtIndex],
1947
+ [enableKeyboardNavigation, itemCount, focusItemAtIndex, horizontalKeyInputs, horizontalKeyStep],
1682
1948
  )
1683
1949
 
1684
1950
  const handleItemFocus = useCallback(
@@ -2048,6 +2314,8 @@ const VirtualScrollInner = <T,>(
2048
2314
  getContentSize: () => scrollPaneRef.current?.getContentSize() ?? -1,
2049
2315
  getViewportSize: () => scrollPaneRef.current?.getViewportSize() ?? -1,
2050
2316
  scrollTo: scrollToHandle,
2317
+ scrollBy,
2318
+ applyWheel,
2051
2319
  scrollToIndex,
2052
2320
  getFenwickTreeTotalHeight: () => fenwickTree.getTotal(),
2053
2321
  getFenwickSize: () => fenwickTree.getSize(),
@@ -2056,7 +2324,7 @@ const VirtualScrollInner = <T,>(
2056
2324
  getScrollAnchor,
2057
2325
  updateItemSize,
2058
2326
  }),
2059
- [scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
2327
+ [scrollToHandle, scrollBy, applyWheel, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
2060
2328
  )
2061
2329
 
2062
2330
  const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
@@ -2065,7 +2333,8 @@ const VirtualScrollInner = <T,>(
2065
2333
  <ScrollPane
2066
2334
  ref={scrollPaneRef}
2067
2335
  contentSize={totalContentHeight}
2068
- viewportSize={viewportSize}
2336
+ viewportSize={viewportSizeProp}
2337
+ onViewportSizeChange={handleViewportSizeChange}
2069
2338
  className={className}
2070
2339
  testId={testId}
2071
2340
  onScroll={handleScroll}
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,
@@ -3,6 +3,13 @@
3
3
  display: flex;
4
4
  }
5
5
 
6
+ /* 自己計測モード (viewportSize 省略) のときだけルートをホストの帯まで伸ばす。
7
+ 明示モードでは content のインライン height がルートを押し広げるため不要で、
8
+ ここで無条件に伸ばすと既存消費者のルート要素の箱が変わってしまう。 */
9
+ .aqvs-scroll-pane[data-self-measured="true"] {
10
+ height: 100%;
11
+ }
12
+
6
13
  .aqvs-scroll-pane-content {
7
14
  box-sizing: border-box;
8
15
  position: relative;
@@ -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
+ }