@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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,124 @@
2
2
 
3
3
  All notable changes to `@aiquants/virtualscroll` are documented here.
4
4
 
5
+ ## 2.5.0 (2026-08-20)
6
+
7
+ **ペイン外入力の橋渡し。** ホイールリスナーは `.aqvs-scroll-pane` にしか付かないため、消費側がペインの
8
+ 外に置いた要素 (列ヘッダー帯・固定フッター・横スクロールバー行など) はホイールの死角になっていました。
9
+ 明細表では列ヘッダーを仮想スクロールの外に固定するのが定石なので、この死角は必ず生じます。
10
+
11
+ ### Added
12
+
13
+ - **`VirtualScrollHandle.scrollBy(delta)`** — ペイン自身のホイールと**同一の意味**でデルタ分スクロールする。
14
+ float のまま相対加算し、スクロールアンカーを張らない (手動スクロール扱いなので既存の保留アンカーは
15
+ むしろ解除される)。適用後の**論理**位置を返す。
16
+ - **`VirtualScrollHandle.applyWheel(event)` / `ScrollPaneHandle.applyWheel(event)`** — ペイン自身の規則で
17
+ 1 つのホイールを適用し、消費したかを返す。**ホイールの適用規則 (軸分解・ズーム素通し・横成分の委譲先・
18
+ スクロール可否・慣性停止・速度倍率) はこの 1 つの関数に閉じており**、ペイン根のリスナーもこれを通る。
19
+ - **`useWheelBridge(target, options?)` / `WheelBridgeTarget`** — ペイン外の要素へペインと同じホイール
20
+ 意味論を与える公開フック。返す ref を対象要素へ渡すだけでよい。**ホイールの意味論に影響する設定は
21
+ 受け取らない** (すべて `applyWheel` の向こう側にあるため、ペイン内と帯の上で挙動が食い違うことが
22
+ 表現できない)。オプションは橋渡しの ON/OFF (`WheelBridgeOptions.enableBridge`、既定 `true`) のみ。引数は
23
+ `{ applyWheel }` を要求する構造的な型なので、`VirtualScroll` / `ScrollPane` のどちらのハンドルでも使える。
24
+ React 19 の `onWheel` は passive で `preventDefault()` が効かないため、フックが `{ passive: false }` で登録する。
25
+ 自前 ref との合成に対して冪等 (**要素ごと**の台帳を持ち、既に張ってある要素へは張り直さない)。
26
+ 台帳を単一スロットにすると、ある要素の ref が呼び直されただけで別の要素のリスナーが剥がれ、
27
+ ヘッダー帯とフッター帯で同じフックを使ったときに生きている帯が入れ替わる。
28
+ - **`horizontalKeyInputs`** (`readonly ("arrow" | "shift-arrow")[]`、既定 `[]`) と **`horizontalKeyStep`**
29
+ (既定 40px) — 行にフォーカスがあるとき `←/→` や `Shift + ←/→` を `onWheelHorizontal` へ横スクロール量
30
+ として流す。段階的な文字列ではなく**種別の配列**なのは、「素の矢印だけ横スクロール、`Shift + ←/→` は
31
+ 消費側の範囲選択に残す」という実在の要求が段階的な union では表現できないため (`behaviorOptions.pointerDragInputs`
32
+ と同じ作法)。既定が空なのは、行キーハンドラが capture フェーズに付くため、既定で `←/→` を消費すると
33
+ ツリーの展開/折りたたみ等を奪ってしまうから。消費するのは `preventDefault()` のみで
34
+ **伝播は止めない** (縦の矢印キーと同じ契約。`stopPropagation()` は `document` / `window` の bubble
35
+ リスナーごとイベントを消し、ホットキーライブラリ (`document.addEventListener("keydown", …)` 形式) や
36
+ キー入力のテレメトリ、消費側が一覧を囲む要素へ張った bubble ハンドラまで巻き添えにする)。
37
+ **行そのものにフォーカスがあるときだけ**働く (行の中の自作スライダー相当などから矢印を奪わない)。
38
+ テキスト選択中の `Shift + ←/→` は選択範囲の伸縮として消費側に残す (判定は `Range.intersectsNode` で
39
+ 行い、`Ctrl+A` のような行を跨ぐ選択も検出する)。`enableKeyboardNavigation: false` との併用は
40
+ 機能が丸ごと無効になるため `Logger.warn` で警告する。`horizontalKeyStep` は**有限かつ正**のみを受け付け、不正値は
41
+ `Logger.warn` のうえキーを消費しない (既定への暗黙の読み替えをしない)。
42
+ - ❗ `onWheelHorizontal` は**キーボード由来の横量も**受け取るようになった。名前は 2.x で公開済みのため
43
+ 据え置き、docstring と仕様書で意味を「横軸が N px 動いた」と明記した。
44
+
45
+ ### Fixed
46
+
47
+ - **`scrollTo` で連続入力を橋渡しすると 2 つの非対称が出る問題を、専用 API の追加と文書化で解消** —
48
+ `scrollTo` は行解決のため絶対位置を `Math.floor` するので 1px 未満のデルタが毎回消え (精密トラックパッドで
49
+ 一切動かない)、`scrollToIndex` 経由でアンカーを張るので以後のサイズ変化のたびに一覧が過去の位置へ
50
+ 戻っていた。アンカーを張ること自体はジャンプ API として正しい仕様なので、`scrollTo` の挙動は変えず
51
+ デルタ用の `scrollBy` を追加し、docstring と仕様書に使い分けと `-1` 番兵の落とし穴を明記した。
52
+ - **README の `tabIndex={-1}` の説明が誤っていた点を訂正** — `tabindex="-1"` はタブ順に**入れません**。
53
+ 実際の問題は「フォーカス可能になり VirtualScroll が実 DOM フォーカスを行へ移すこと」です。
54
+
55
+ ### Changed
56
+
57
+ - `ScrollPane` のホイール処理を `applyWheel` として切り出し、命令ハンドルへ公開した。挙動は 1 点だけ
58
+ 意図的に変わる: 「縦成分が無い」の判定を生の `deltaY` ではなく **px 換算後**の値で行う。
59
+ `deltaMode: DOM_DELTA_PAGE` かつ帯が 0 のとき (自己計測の初回コミット等) 換算後は 0 になり、
60
+ 従来は `preventDefault()` だけして 1px も動かず**祖先のスクロールも奪って**いた。適用できる量が
61
+ 無いイベントは消費しない、に揃えた。それ以外は不変 (既存の wheel 系テスト全件で回帰なしを確認)。
62
+ - 内部の軸分解 `resolveWheelAxes` は **ctrl 押下でもデルタを 0 に潰さなくなった** (事実だけを返す解決器へ)。
63
+ 「ズームは横取りしない」という判断は適用側が持つ。従来は解決器が 0 を詰めていたため適用側の ctrl 判定が
64
+ 到達不能な飾りになっており、「ctrl+shift+ホイールを横スクロールとして食う」退行を検出できなかった。
65
+ - コールバック参照の同期を `useEffect` から `useLayoutEffect` へ変更 (差し替え直後の入力が 1 フレーム古い
66
+ コールバックを読む窓を閉じる)。
67
+
68
+ - **`ScrollPane.applyWheel` は方針値を ref から読む** — lexical に閉じ込めると、消費側が保持したハンドルが
69
+ その時点の方針を凍結する (実測: 一覧が絞り込みでスクロール不能になった後も、保持済みハンドルは
70
+ `preventDefault` し続けてページのスクロールを殺す)。`VirtualScrollHandle.applyWheel` は毎回 ref を辿るため
71
+ 元から凍結せず、両者の非対称を解消した。
72
+
73
+ - **ペインの入れ子でホイールが 2 回適用される問題を修正** — 内側のペインが消費したホイールが
74
+ バブリングで外側のペインにも届き、1 ノッチで内外の一覧が両方動いていた (実測: どちらも 100px)。
75
+ `applyWheel` の内側に「**本パッケージが**消費した」印 (`WeakSet<WheelEvent>`) を持たせ、
76
+ 呼び出し元がどれでも二重適用を弾くようにした。❗ `event.defaultPrevented` では代用できない
77
+ (「誰かが止めた」と区別が付かず、ページ全体のスクロールロック中に一覧がまったく
78
+ スクロールできなくなる)。
79
+ - **行の中の要素にフォーカスがあるとき矢印キーを奪わないようにした** (縦横・PageUp/PageDown すべて)。
80
+ 従来は `role` で実装したスライダー相当などが keydown を**どちらのフェーズでも受け取れず**、
81
+ さらに縦矢印ではフォーカスが隣の行へ飛んでいた。❗ **これは縦キー操作の挙動変更である。**
82
+ 行の中の要素にフォーカスがある状態での `↑↓` / `PageUp` / `PageDown` は、行の走査ではなく
83
+ その要素へ届くようになった。
84
+ - **`useWheelBridge` の対象を差し替えても古い一覧を掴み続けないようにした** — ref コールバックの
85
+ identity を固定し、対象ハンドルを ref 越しに読む。クリーンアップを捨てる合成 ref では台帳が
86
+ 「既に張ってある」と判断して張り直さないため、差し替え後の一覧が永久に動かなかった。
87
+ - **警告の網羅と抑制** — `horizontalKeyInputs` を指定しつつ `onWheelHorizontal` が無い組み合わせも
88
+ 警告するようにした (`@aiquants/directory-tree` のように `onWheelHorizontal` を `Omit` で封じる
89
+ ラッパーで踏みやすい)。`horizontalKeyStep` の検証警告は keydown 毎ではなく prop 変化時に出す
90
+ (キーリピート中にコンソールが溢れるため)。
91
+
92
+ ### Notes
93
+
94
+ - `resolveWheelAxes` / `ResolvedWheelAxes` は**公開していません** (内部実装)。公開すると `16px` 行高・
95
+ 同値の扱い・4 フィールドの形が恒久的に凍結されます。消費側が必要とするのは「ペインと同じ意味論で
96
+ ホイールを流し込むこと」であり、それは `useWheelBridge` が満たします。
97
+ - 行は依然としてタブ順に入りません (`tabIndex={-1}`)。キーボードのみで横スクロールへ到達させたい消費側は
98
+ `handle.focusItemAtIndex(0)` などでフォーカスの入口を用意してください (ロービングタブインデックスの
99
+ 導入は全消費側のタブ順を変えるため見送り)。デモ (`/horizontal`) にその実演ボタンを追加しました。
100
+ - 既知の限界 (いずれも仕様書 §11.4 に記載): 行内でドラッグ選択/ダブルクリックすると選択が残り、それを
101
+ 畳むキーボード手段が無いため `Shift + ←/→` が効かないままになる / Shadow DOM 内の選択は検出できない /
102
+ 横スクロールは支援技術へ何も伝わらない / 縦スクロールでフォーカス行が仮想化で消えるとフォーカスが
103
+ `<body>` へ落ちる / 位置を量子化する消費側では `horizontalKeyStep` が量子未満だと 1px も動かない /
104
+ 連続スクロール速度は OS のキーリピート設定に依存する (環境で最大 2.8 倍程度の差)。
105
+ - **橋渡しはペインの外の要素にだけ張ってください。** 内側の要素はペイン自身のリスナーが既に拾っています
106
+ (二重適用は消費印が弾きますが、意味のない登録です)。
107
+ - **既知の限界 (未修正・既存)**: 中断されたトランジション。`sizeRef` / `wheelPolicyRef` は render 本体で
108
+ 代入するため、`startTransition` 内の絞り込みが Suspense で中断されると**コミットされなかった**
109
+ `contentSize` が残り、保留中は「スクロール可能な一覧なのにホイールが効かない」状態になります。
110
+ 両 ref を layout effect へ移すのが筋ですが、それだけではタップスクロールの走破が 1 コミット遅れて
111
+ 短くなる退行が実機 E2E で出ることを確認したため、本リリースでは見送りました (恒久対応には
112
+ クランプ経路の見直しが必要)。❗ 2 つの ref は**必ず同じタイミングで代入すること** — 片方だけを
113
+ layout effect にすると「消費はするがクランプは古い寸法」で一覧が先頭へ飛びます。
114
+ - **既知の限界 (未修正)**: ペインは端に達してもホイールを消費するため、ページへスクロールが連鎖しません
115
+ (いわゆるスクロールトラップ)。ネイティブのスクロール領域と異なる挙動で、修正には端判定の追加が必要です。
116
+ 本リリースの範囲外としました。
117
+ - 行の中の要素へフォーカスが入ると、キーボードだけで行へ戻る手段がありません (行はタブ順に入らないため)。
118
+ 消費側で `Escape` などの復帰口を用意してください。
119
+ - `Omit<VirtualScrollProps, "onWheelHorizontal">` で横軸を封じているラッパーは、`horizontalKeyInputs` と
120
+ `horizontalKeyStep` も同じ `Omit` に加えてください。この 2 つがトップレベルにあるのは、1 箇所の `Omit` で
121
+ 横軸のシーム全体を封じられるようにするためです。
122
+
5
123
  ## 2.4.0 (2026-08-19)
6
124
 
7
125
  **帯の自己計測 (`viewportSize` の optional 化)。** 自分の高さを自分で測れるコンポーネントが消費側に
package/README.md CHANGED
@@ -183,6 +183,123 @@ function Grid() {
183
183
  A runnable version lives in the demo at **`/horizontal`** (`pnpm demo:dev`), and
184
184
  [`@aiquants/directory-tree`](../directory-tree)'s TreeGrid mode uses this exact pattern in production.
185
185
 
186
+ ## Bridging input from outside the pane
187
+
188
+ The wheel listener lives on `.aqvs-scroll-pane` only, so anything you render **outside** it — a frozen
189
+ column header, a sticky footer, the horizontal scrollbar row, an empty-state panel — is a wheel dead
190
+ zone. Data tables pin the header outside the virtual list by convention, so this gap is the norm, not
191
+ an edge case.
192
+
193
+ Use `useWheelBridge`: it gives that element the pane's own wheel semantics.
194
+
195
+ ```tsx
196
+ import { useRef } from 'react'
197
+ import { useWheelBridge, VirtualScroll, type VirtualScrollHandle } from '@aiquants/virtualscroll'
198
+
199
+ const listRef = useRef<VirtualScrollHandle>(null)
200
+ const headerRef = useWheelBridge(listRef)
201
+
202
+ <div ref={headerRef}>frozen header — outside the pane</div>
203
+ <VirtualScroll ref={listRef} onWheelHorizontal={setScrollX} ...>{renderRow}</VirtualScroll>
204
+ ```
205
+
206
+ **The hook takes no configuration that affects wheel semantics, and that is the point.** Wheel speed, the horizontal sink, whether
207
+ the list can scroll, inertia and axis resolution all live behind the handle, in one function
208
+ (`applyWheel`) that the pane's own listener goes through too. Give the bridge its own settings and the
209
+ same grid ends up scrolling at one speed over the rows and another over the header — we measured exactly that
210
+ — 30px vs 10px for one wheel tick at `wheelSpeedMultiplier: 3` — in a work-in-progress build that did give
211
+ the bridge its own settings. With a single home for the rules, that
212
+ mismatch is not expressible. The hook's job is `{ passive: false }` registration, teardown, and the
213
+ `enableBridge` on/off switch — nothing else. Deciding whether an event was already applied belongs to
214
+ `applyWheel`, which tracks the events **this package** consumed (see below).
215
+
216
+ Its one option is `enableBridge` (default `true`) — a plain on/off switch, not a semantic knob. Pass
217
+ `{ enableBridge: false }` to stop bridging without unmounting the element:
218
+
219
+ ```tsx
220
+ const headerRef = useWheelBridge(listRef, { enableBridge: !isEditing })
221
+ ```
222
+
223
+ It works with `ScrollPane` directly too: the parameter is typed against `WheelBridgeTarget`
224
+ (`{ applyWheel }`), which both `VirtualScrollHandle` and `ScrollPaneHandle` satisfy.
225
+
226
+ Composing with your own ref is safe — the hook keeps a per-element ledger and skips re-attaching to an
227
+ element it already holds, so the common inline merge cannot pile up listeners (without it, four
228
+ re-renders leave five live wheel listeners on one element). Return the hook's value so React 19 can run
229
+ its cleanup on unmount:
230
+
231
+ ```tsx
232
+ <div ref={(node) => { myRef.current = node; return headerRef(node) }} />
233
+ ```
234
+
235
+ Dropping that `return` still works — the ledger keeps it correct — but the listener then stays on the
236
+ element until the element itself is garbage-collected.
237
+
238
+ The ledger is keyed **per element**, so one hook can serve several bands (a header and a footer) without
239
+ them fighting over a single slot.
240
+
241
+ ⚠️ **The pane does not chain scrolling to the page at its edges.** It consumes the wheel even when
242
+ already at position 0 or at the maximum, so a user wheeling down a long page has to move the pointer off
243
+ the list to continue. This differs from a native scroll container and is a known limitation.
244
+
245
+ ⚠️ **`onWheel` cannot do this.** React 19 registers `onWheel` as a passive listener, so `preventDefault()`
246
+ is ignored and the page scrolls instead. The hook attaches a `{ passive: false }` listener itself.
247
+
248
+ ⚠️ **Only bridge elements OUTSIDE the pane.** Anything inside the pane is already covered by the pane's
249
+ own listener, so bridging it is a redundant registration — the consumed-event mark keeps it from being
250
+ applied twice, but the extra listener is pure overhead on the wheel hot path.
251
+
252
+ ⚠️ **Do not bridge with `scrollTo`.** It is a *jump* API: it floors the absolute position (sub-pixel
253
+ trackpad deltas vanish entirely) and pins a scroll anchor (every later size change re-pins the list to
254
+ that row, so filtering a list makes it jump back to where it used to be). `scrollBy` is the delta API and
255
+ takes the pane's own path. Also never write `scrollTo(handle.getScrollPosition() + delta)` — the getter
256
+ returns the `-1` sentinel while the pane is unconnected.
257
+
258
+ ### Horizontal scrolling from the keyboard
259
+
260
+ With `onWheelHorizontal` wired **and `behaviorOptions.enableKeyboardNavigation` left on** (the default),
261
+ `horizontalKeyInputs` lets a focused row emit horizontal deltas too:
262
+
263
+ | Value | Keys | Use it when |
264
+ | --- | --- | --- |
265
+ | `[]` (default) | — | Never steal the row's own arrow handling |
266
+ | `["shift-arrow"]` | `Shift + ←/→` | Safe alongside tree expand/collapse and grid cell navigation |
267
+ | `["arrow"]` | `←/→` | Grids that bind `Shift + ←/→` to range selection |
268
+ | `["arrow", "shift-arrow"]` | both | Rows that use no arrow keys at all |
269
+
270
+ It is an array rather than a graduated string because `["arrow"]` — plain arrows scroll, `Shift + ←/→`
271
+ stays with your range selection — is a real requirement that `"none" | "shift-arrows" | "arrows"`
272
+ cannot express, since `"arrows"` subsumes `"shift-arrows"`. `behaviorOptions.pointerDragInputs` takes a
273
+ type array for the same reason.
274
+
275
+ The default is empty on purpose: the package's row key handler runs in the **capture** phase, ahead of
276
+ your row's own handler. When it consumes a key it calls `preventDefault()` only and deliberately does
277
+ **not** stop propagation — `stopPropagation()` would delete the event from `document` and `window`
278
+ bubble listeners too, breaking global hotkey libraries and keydown telemetry. Check `defaultPrevented`
279
+ in your own handler, exactly as you would for the vertical arrows.
280
+
281
+ It also fires only when the row **itself** is the event target. Focus sitting on something inside the
282
+ row — a `role="slider"` cell, a link, a nested `overflow-x:auto` region — is never hijacked, so you do
283
+ not have to avoid `["arrow"]` just because your rows contain arrow-key widgets.
284
+
285
+ `Shift + ←/→` is left alone while a non-collapsed text selection sits inside the row, so keyboard users
286
+ can still extend a selection. `horizontalKeyStep` (default 40) must be finite and positive; anything
287
+ else logs a warning and leaves the key untouched rather than silently substituting the default.
288
+
289
+ ⚠️ **This does nothing when `enableKeyboardNavigation` is `false`** — the row key handler is what reads
290
+ these keys, and turning navigation off removes it. The package emits a `Logger.warn` when it sees that
291
+ combination. Note the next section tells `listbox` / `tree` / `grid` authors to turn navigation off; if
292
+ you follow both, your arrow keys are dead. Pick one.
293
+
294
+ ⚠️ **Rows are not in the page tab sequence** (`tabIndex={-1}`). This feature fires only while a row has
295
+ focus, so if you want keyboard-only users to reach it, give them an entry point — for example call
296
+ `handle.focusItemAtIndex(0)` when the list receives focus.
297
+
298
+ ⚠️ **Wrappers that own the horizontal axis should seal these too.** If you re-export VirtualScroll with
299
+ `Omit<VirtualScrollProps, 'onWheelHorizontal'>`, add `horizontalKeyInputs` and `horizontalKeyStep` to
300
+ that same `Omit`. They sit at the top level, next to `onWheelHorizontal`, precisely so one `Omit` closes
301
+ the whole horizontal seam.
302
+
186
303
  ## Composite widget roles (listbox / tree / grid)
187
304
 
188
305
  Building a `listbox`, `tree` or `grid` on top of VirtualScroll? Put the role on **`contentProps`**,
@@ -195,8 +312,10 @@ not on a wrapper around `<VirtualScroll>`:
195
312
  getItemHeight={() => 36}
196
313
  viewportSize={300}
197
314
  contentProps={{ id: listboxId, role: "listbox", "aria-label": "Warehouses" }}
198
- // Required: with keyboard navigation ON, every row wrapper gets tabIndex={-1}, which puts the
199
- // popup in the page tab sequence AND makes the wrappers opaque to the owned-element relationship.
315
+ // Required: with keyboard navigation ON, every row wrapper gets tabIndex={-1}. That does not add a
316
+ // tab stop, but it makes the wrappers focusable VirtualScroll then moves real DOM focus onto a row
317
+ // — and it makes them opaque to the owned-element relationship.
318
+ // ⚠️ This also disables `horizontalKeyInputs` — the row key handler goes away with it.
200
319
  behaviorOptions={{ enableKeyboardNavigation: false }}>
201
320
  {(option) => <div role="option" aria-selected={false}>{option.label}</div>}
202
321
  </VirtualScroll>
@@ -235,7 +354,9 @@ exists in the DOM.
235
354
  | `testId` | `string` | ❌ | Emitted as `data-testid` on the scroll root. DOM hooks should use `data-*`, never class selectors |
236
355
  | `onScroll` | `(position: number, totalHeight: number) => void` | ❌ | Scroll event handler |
237
356
  | `onRangeChange` | `(range: VirtualScrollRange) => void` | ❌ | Range change handler |
238
- | `onWheelHorizontal` | `(deltaX: number) => void` | ❌ | Opt-in horizontal wheel delegation. When set, horizontal-dominant wheel / trackpad gestures (and shift+wheel) are delegated to this handler so the parent can implement horizontal scrolling. When omitted, horizontal gestures and shift+wheel bypass vertical scrolling so parent native scrolling works. See [Horizontal Scrolling](#horizontal-scrolling). |
357
+ | `onWheelHorizontal` | `(deltaX: number) => void` | ❌ | Opt-in horizontal delegation. Horizontal-dominant wheel / trackpad gestures (and shift+wheel) are delegated to this handler so the parent can implement horizontal scrolling. ⚠️ **Keyboard deltas arrive here too** when `horizontalKeyInputs` is set. When omitted, horizontal gestures bypass vertical scrolling so parent native scrolling works. See [Horizontal Scrolling](#horizontal-scrolling). |
358
+ | `horizontalKeyInputs` | `readonly ("arrow" \| "shift-arrow")[]` | ❌ | Keyboard gestures that emit a horizontal delta through `onWheelHorizontal` (default: `[]`, so row-level arrow handling is never stolen). Fires only when the row itself is the event target. ⚠️ Requires `behaviorOptions.enableKeyboardNavigation` (default `true`) — with it off the rows have no key handler at all and this prop does nothing (the package warns) |
359
+ | `horizontalKeyStep` | `number` | ❌ | Pixels per horizontal arrow press (default: 40). Must be finite and positive; anything else warns and leaves the key untouched |
239
360
  | `background` | `ReactNode` | ❌ | Background element |
240
361
  | `initialScrollIndex` | `number` | ❌ | Initial scroll index |
241
362
  | `initialScrollOffset` | `number` | ❌ | Initial scroll offset (**logical** px). Not suitable for restoring positions of variable-height lists across remounts — use `initialScrollAnchor` |
@@ -265,7 +386,7 @@ exists in the DOM.
265
386
  | --- | --- | --- |
266
387
  | `enablePointerDrag` | `boolean` | Enable dragging the content area to scroll (default: true). ⚠️ Setting this to `false` removes the only way to scroll on touch devices — the pane is transform-based and has no native scroller. Use `pointerDragInputs` to exclude a single pointer type instead. |
267
388
  | `pointerDragInputs` | `readonly ("mouse" \| "pen" \| "touch")[]` | Pointer types allowed to drag-scroll the content area (default: all three). `touch-action: none` is applied only when `"touch"` or `"pen"` is included. |
268
- | `enableKeyboardNavigation` | `boolean` | Enable keyboard navigation (default: true) |
389
+ | `enableKeyboardNavigation` | `boolean` | Enable keyboard navigation (default: true). Arrow / Page keys move row focus. ⚠️ They fire **only when the row wrapper itself is the event target** — focus inside a row (a `role="slider"` cell, a link, a nested scroll region) keeps its own keys |
269
390
  | `wheelSpeedMultiplier` | `number` | Multiplier for mouse wheel scrolling speed (default: 1) |
270
391
  | `inertiaOptions` | `ScrollPaneInertiaOptions` | Physics tuning for drag inertia |
271
392
  | `clipItemHeight` | `boolean` | Whether to clip item height (default: false) |
@@ -275,11 +396,13 @@ exists in the DOM.
275
396
 
276
397
  | Method | Type | Description |
277
398
  | --- | --- | --- |
278
- | `scrollTo` | `(position: number \| ((prev: number) => number)) => number` | Scroll to a **logical** position (updater receives the current logical position). Returns the applied **logical** position (2.0.0) |
399
+ | `scrollTo` | `(position: number \| ((prev: number) => number)) => number` | **Jump** to a **logical** position (updater receives the current logical position). Returns the applied **logical** position (2.0.0). ⚠️ Not for bridging continuous input — see [Bridging input from outside the pane](#bridging-input-from-outside-the-pane) |
400
+ | `scrollBy` | `(delta: number) => number` | Scroll **by a delta** with the pane's own wheel semantics (float accumulation, no anchor). Use this — not `scrollTo` — to bridge continuous input from outside the pane. Returns the applied **logical** position |
401
+ | `applyWheel` | `(event: WheelEvent) => boolean` | Apply one wheel event with the pane's own rules; returns whether it was consumed. The single entry point for wheel input originating outside the pane — normally reached through `useWheelBridge` |
279
402
  | `scrollToIndex` | `(index: number, options?: { align?: "top" \| "bottom" \| "center"; offset?: number }) => void` | Scroll to specific item index with optional alignment and offset |
280
403
  | `getScrollPosition` | `() => number` | Get current **logical** scroll position (2.0.0; `-1` when the pane is not connected) |
281
- | `getContentSize` | `() => number` | Get total content size (insets included) |
282
- | `getViewportSize` | `() => number` | Get viewport size |
404
+ | `getContentSize` | `() => number` | Get total content size (insets included). Returns the `-1` sentinel while the pane is unconnected |
405
+ | `getViewportSize` | `() => number` | Get viewport size. Returns the `-1` sentinel while the pane is unconnected |
283
406
  | `focusItemAtIndex` | `(index: number, options?: { ensureVisible?: boolean }) => void` | Focus item at specific index |
284
407
  | `getRange` | `() => VirtualScrollRange` | Get current range information (updated one render behind) |
285
408
  | `getScrollAnchor` | `() => { index: number; offsetPx: number } \| null` | Capture the current top visible row anchor for exact restore via `initialScrollAnchor` (`null` when `itemCount` is 0) |
@@ -152,6 +152,16 @@ export type ScrollPaneHandle = {
152
152
  getScrollPosition: () => number;
153
153
  getContentSize: () => number;
154
154
  getViewportSize: () => number;
155
+ /**
156
+ * Applies one wheel event with the pane's own rules; returns whether it was consumed.
157
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す。
158
+ *
159
+ * ペインの**外**に置いた要素 (列ヘッダー帯など) のホイールを、ペイン内と寸分違わぬ意味論で
160
+ * 流し込むための唯一の口。軸分解・速度倍率・スクロール可否・横委譲先・慣性停止のすべてが
161
+ * ペイン側の 1 箇所で決まるため、消費側が規則を書き直したり食い違わせたりできない。
162
+ * 直接使うより `useWheelBridge` (passive:false 登録と後始末込み) を推奨する。
163
+ */
164
+ applyWheel: (event: WheelEvent) => boolean;
155
165
  };
156
166
  /**
157
167
  * A component that provides a scrollable view with a custom scrollbar.
@@ -152,6 +152,16 @@ export type ScrollPaneHandle = {
152
152
  getScrollPosition: () => number;
153
153
  getContentSize: () => number;
154
154
  getViewportSize: () => number;
155
+ /**
156
+ * Applies one wheel event with the pane's own rules; returns whether it was consumed.
157
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す。
158
+ *
159
+ * ペインの**外**に置いた要素 (列ヘッダー帯など) のホイールを、ペイン内と寸分違わぬ意味論で
160
+ * 流し込むための唯一の口。軸分解・速度倍率・スクロール可否・横委譲先・慣性停止のすべてが
161
+ * ペイン側の 1 箇所で決まるため、消費側が規則を書き直したり食い違わせたりできない。
162
+ * 直接使うより `useWheelBridge` (passive:false 登録と後始末込み) を推奨する。
163
+ */
164
+ applyWheel: (event: WheelEvent) => boolean;
155
165
  };
156
166
  /**
157
167
  * A component that provides a scrollable view with a custom scrollbar.
@@ -1 +1 @@
1
- {"version":3,"file":"ScrollPane.d.ts","sourceRoot":"","sources":["../src/ScrollPane.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ScrollPane.d.ts","sourceRoot":"","sources":["../src/ScrollPane.tsx"],"names":[],"mappings":"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"}
@@ -36,11 +36,54 @@ export type VirtualScrollRange = {
36
36
  */
37
37
  export type VirtualScrollHandle = {
38
38
  /**
39
- * Scrolls to a LOGICAL position (updater form receives the current logical position).
39
+ * Jumps to a LOGICAL position (updater form receives the current logical position).
40
40
  * Returns the applied (clamped) LOGICAL position.
41
- * 論理位置へスクロール (updater は現在の論理位置を受け取る)。適用後 (クランプ後) の論理位置を返す。
41
+ * 論理位置へ**ジャンプ**する (updater は現在の論理位置を受け取る)。適用後 (クランプ後) の論理位置を返す。
42
+ *
43
+ * ❗ **連続的なスクロール入力 (ホイール等) の橋渡しには使わないこと。`scrollBy` を使う。**
44
+ * これは「行を狙って飛ぶ」ための API であり、ペイン自身のホイール処理とは意味が 2 点異なる:
45
+ *
46
+ * 1. **絶対位置を `Math.floor` する** (行解決のため)。デルタを足して渡すと 1px 未満の移動が
47
+ * 毎回消えるため、精密トラックパッドでは**一切動かない**。
48
+ * 2. **スクロールアンカーを張る** (`scrollToIndex` 経由)。狙った行に留まり続けるための仕様だが、
49
+ * 以後 `contentSize` / `itemCount` / `viewportSize` / インセットが変わるたびにドリフト補正が
50
+ * その行へ**再ピン留め**するため、絞り込みなどで一覧が過去の位置へ戻って見える。
51
+ *
52
+ * ❗ `scrollTo(handle.getScrollPosition() + delta)` と書かないこと。ペイン未接続時の
53
+ * `getScrollPosition()` は番兵 `-1` を返すため `-1 + delta` が混入する。相対移動は `scrollBy`
54
+ * (推奨) か updater 形式 `scrollTo(prev => prev + delta)` を使う (updater の `prev` は内部の
55
+ * 最新位置から取るため番兵が混入しない)。
42
56
  */
43
57
  scrollTo: (position: number | ((prev: number) => number)) => number;
58
+ /**
59
+ * Scrolls by a delta using the exact semantics of the pane's own wheel handling.
60
+ * ペイン自身のホイール処理と**同一の意味**でデルタ分スクロールする。
61
+ *
62
+ * ペインの外に置いた要素 (列ヘッダー帯・固定フッター・横スクロールバー行など) の上での
63
+ * ホイールを一覧へ橋渡しするための API。ホイールリスナーは `.aqvs-scroll-pane` にしか付かないため、
64
+ * ペイン外はホイールの死角になる。この口はその死角を埋める。
65
+ *
66
+ * `scrollTo` との違い (どちらもペイン自身のホイールと同じ扱いにするための選択):
67
+ *
68
+ * - **float のまま相対加算する** (丸めない)。1px 未満のデルタも積み上がって動く。
69
+ * - **アンカーを張らない**。手動スクロールとして扱われるため、保留アンカーはむしろ**解除される**
70
+ * (ペイン上でホイールを回したときと同じ)。以後のサイズ変化で位置が巻き戻ることがない。
71
+ * - 慣性は停止する (ペイン上のホイールと同じ)。
72
+ *
73
+ * @param delta - Signed pixels to scroll (positive scrolls down) / 符号付きの移動量 (正で下方向)
74
+ * @returns The applied (clamped) LOGICAL position / 適用後 (クランプ後) の論理位置
75
+ */
76
+ scrollBy: (delta: number) => number;
77
+ /**
78
+ * Applies one wheel event with the pane's own rules; returns whether it was consumed.
79
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す。
80
+ *
81
+ * 一覧の**外**に置いた要素 (列ヘッダー帯・固定フッター・空表示など) のホイールを、ペイン内と
82
+ * 寸分違わぬ意味論で流し込むための唯一の口。軸分解・速度倍率・スクロール可否・横成分の委譲先・
83
+ * 慣性停止のすべてがペイン側の 1 箇所で決まるため、消費側が規則を書き直したり食い違わせたりできない。
84
+ * 直接呼ぶより `useWheelBridge` (passive:false 登録と後始末込み) を使うこと。
85
+ */
86
+ applyWheel: (event: WheelEvent) => boolean;
44
87
  /**
45
88
  * Current LOGICAL scroll position. -1 when the pane is not connected.
46
89
  * 現在の論理スクロール位置。ペイン未接続時は -1。
@@ -198,8 +241,71 @@ export type VirtualScrollProps<T> = {
198
241
  onItemFocus?: (index: number) => void;
199
242
  scrollBarOptions?: VirtualScrollScrollBarOptions;
200
243
  behaviorOptions?: VirtualScrollBehaviorOptions;
201
- /** Delegates horizontal wheel/trackpad delta to an upstream owner (e.g. a frozen-column grid). / 横ホイール量を上流へ委譲する。 */
244
+ /** Delegates horizontal wheel/trackpad delta to an upstream owner (e.g. a frozen-column grid). / 横ホイール量を上流へ委譲する。
245
+ *
246
+ * ❗ **ホイール由来だけの口ではない。** `horizontalKeyInputs` を指定すると**キーボード由来の横量**も
247
+ * ここへ流れる。名前は 2.x で公開済みのため据え置くが、意味は「横軸が N px 動いた。横軸はあなたの所有物である」。
248
+ */
202
249
  onWheelHorizontal?: (deltaX: number) => void;
250
+ /**
251
+ * Keyboard gestures that emit a horizontal scroll delta through `onWheelHorizontal` (default: none).
252
+ * `onWheelHorizontal` へ横スクロール量を流すキーボード操作の種別 (既定: 無効)。
253
+ *
254
+ * - `[]` / 未指定 (既定): 横キーボードスクロールを行わない。
255
+ * - `["shift-arrow"]`: `Shift + ←/→` のみ。木の展開/折りたたみ (`←/→`) やグリッドのセル移動と
256
+ * 衝突しないため、既存の行 UI を持つ消費側でも安全に有効化できる。
257
+ * - `["arrow"]`: 素の `←/→` のみ。`Shift + ←/→` を選択範囲の拡張に使うグリッド向け。
258
+ * - `["arrow", "shift-arrow"]`: 両方。
259
+ *
260
+ * ❗ **配列なのは 4 状態が独立に必要だからである。** `"none" | "shift-arrows" | "arrows"` のような
261
+ * 段階的な文字列にすると `"arrows"` が `"shift-arrows"` を含んでしまい、「素の矢印だけ横スクロール、
262
+ * `Shift + ←/→` は消費側の範囲選択に残す」(Excel / データグリッドの標準) が**表現できない**。
263
+ * 同じ理由で種別配列を採るのが `pointerDragInputs` であり、本パッケージの既存の作法に揃えてある。
264
+ *
265
+ * ❗ **既定が無効なのは、行ハンドラを奪わないためである。** 本パッケージの行キーハンドラは
266
+ * capture フェーズに付くため、消費側の行 (bubble) より先に走る。既定で `←/→` を消費すると
267
+ * ツリーの展開/折りたたみのような既存操作を奪ってしまう。消費するのは `preventDefault()` のみで
268
+ * **伝播は止めない** (縦の矢印キーと同じ契約)。`stopPropagation()` は行ハンドラだけでなく
269
+ * `document` / `window` の bubble リスナーごとイベントを消し、ホットキーライブラリや
270
+ * キー入力のテレメトリまで巻き添えにするため採らない。消費側が二重動作を避ける手段は
271
+ * `defaultPrevented` の確認である。
272
+ *
273
+ * 前提: 横軸を所有するのは消費側なので、`onWheelHorizontal` が未指定なら何も起きない
274
+ * (キーイベントも消費しない)。行にフォーカスがあるときだけ働くため
275
+ * `behaviorOptions.enableKeyboardNavigation` も必要。
276
+ *
277
+ * ❗ **符号は物理キー基準** (`→` が正、`←` が負) である。横軸の向きを知っているのは消費側だけなので、
278
+ * RTL (`direction: rtl`) の一覧では消費側が受け取った値を反転すること。パッケージ側で
279
+ * `direction` を推測すると、横スクロールの実体 (CSS 変数・`scrollLeft`・transform) がどの要素の
280
+ * どの座標系かを知らないまま符号を決めることになり、当たらない前提を増やすだけになる。
281
+ *
282
+ * ❗ **働くのは行そのものにフォーカスがあるときだけである** (`event.target === event.currentTarget`)。
283
+ * 行の**中**の要素 (自作のタブ・ラジオ相当・スライダー相当など `role` だけで矢印キー操作を実装した
284
+ * ウィジェット、リンク、ネイティブの横スクロール領域) にフォーカスがある間は奪わない。
285
+ * 入力要素の allowlist だけでは `role` 実装のウィジェットを守れないため、対象そのもので判定する。
286
+ *
287
+ * 長押しの連続スクロールは **OS のキーリピート**に委ねる (`keydown` が繰り返し届く)。スクロールバーの
288
+ * 矢印ボタンが明示的なリピートタイマーを持つ (`ARROW_HOLD_DELAY` / `ARROW_HOLD_INTERVAL`) のは
289
+ * ポインタ押下にリピートが存在しないためで、キーボードとの非対称は入力機構の差に由来する。
290
+ *
291
+ * ❗ **`onWheelHorizontal` と同じ「横軸のシーム」に属するため、意図的にトップレベルに置いてある。**
292
+ * `behaviorOptions` の中へ入れると、横軸を自前で所有するラッパー (例: `@aiquants/directory-tree` は
293
+ * `Omit<VirtualScrollProps, "onWheelHorizontal">` で横軸を封じている) が `behaviorOptions` を
294
+ * そのまま素通しするため、封じたはずのシームへ横から到達できてしまう。
295
+ */
296
+ horizontalKeyInputs?: readonly ("arrow" | "shift-arrow")[];
297
+ /**
298
+ * Pixels emitted per horizontal arrow key press (default: 40, matching browser arrow scrolling).
299
+ * 横矢印キー 1 回あたりの移動量 (px。既定 40 = ブラウザの矢印スクロール相当)。
300
+ *
301
+ * ❗ **有限かつ正の値のみを受け付ける。** `0` / 負値 / `NaN` / `Infinity` を渡すと
302
+ * **キーを消費しない** (既定値へ黙って読み替えることはしない)。0 を既定へ差し替えると
303
+ * 呼び出し側の明示的な指定を握り潰し、0 のまま通すと「キーを食うのに 1px も動かない」
304
+ * 死んだ操作になる。
305
+ * 警告は `keydown` ではなく **prop の変化時に 1 回だけ**出す (キーリピート中に
306
+ * コンソールが溢れるため)。
307
+ */
308
+ horizontalKeyStep?: number;
203
309
  /**
204
310
  * ARIA / identity attributes applied to the scrollable CONTENT element — the element that
205
311
  * directly owns the rendered rows and excludes the scrollbar and overlay chrome.
@@ -36,11 +36,54 @@ export type VirtualScrollRange = {
36
36
  */
37
37
  export type VirtualScrollHandle = {
38
38
  /**
39
- * Scrolls to a LOGICAL position (updater form receives the current logical position).
39
+ * Jumps to a LOGICAL position (updater form receives the current logical position).
40
40
  * Returns the applied (clamped) LOGICAL position.
41
- * 論理位置へスクロール (updater は現在の論理位置を受け取る)。適用後 (クランプ後) の論理位置を返す。
41
+ * 論理位置へ**ジャンプ**する (updater は現在の論理位置を受け取る)。適用後 (クランプ後) の論理位置を返す。
42
+ *
43
+ * ❗ **連続的なスクロール入力 (ホイール等) の橋渡しには使わないこと。`scrollBy` を使う。**
44
+ * これは「行を狙って飛ぶ」ための API であり、ペイン自身のホイール処理とは意味が 2 点異なる:
45
+ *
46
+ * 1. **絶対位置を `Math.floor` する** (行解決のため)。デルタを足して渡すと 1px 未満の移動が
47
+ * 毎回消えるため、精密トラックパッドでは**一切動かない**。
48
+ * 2. **スクロールアンカーを張る** (`scrollToIndex` 経由)。狙った行に留まり続けるための仕様だが、
49
+ * 以後 `contentSize` / `itemCount` / `viewportSize` / インセットが変わるたびにドリフト補正が
50
+ * その行へ**再ピン留め**するため、絞り込みなどで一覧が過去の位置へ戻って見える。
51
+ *
52
+ * ❗ `scrollTo(handle.getScrollPosition() + delta)` と書かないこと。ペイン未接続時の
53
+ * `getScrollPosition()` は番兵 `-1` を返すため `-1 + delta` が混入する。相対移動は `scrollBy`
54
+ * (推奨) か updater 形式 `scrollTo(prev => prev + delta)` を使う (updater の `prev` は内部の
55
+ * 最新位置から取るため番兵が混入しない)。
42
56
  */
43
57
  scrollTo: (position: number | ((prev: number) => number)) => number;
58
+ /**
59
+ * Scrolls by a delta using the exact semantics of the pane's own wheel handling.
60
+ * ペイン自身のホイール処理と**同一の意味**でデルタ分スクロールする。
61
+ *
62
+ * ペインの外に置いた要素 (列ヘッダー帯・固定フッター・横スクロールバー行など) の上での
63
+ * ホイールを一覧へ橋渡しするための API。ホイールリスナーは `.aqvs-scroll-pane` にしか付かないため、
64
+ * ペイン外はホイールの死角になる。この口はその死角を埋める。
65
+ *
66
+ * `scrollTo` との違い (どちらもペイン自身のホイールと同じ扱いにするための選択):
67
+ *
68
+ * - **float のまま相対加算する** (丸めない)。1px 未満のデルタも積み上がって動く。
69
+ * - **アンカーを張らない**。手動スクロールとして扱われるため、保留アンカーはむしろ**解除される**
70
+ * (ペイン上でホイールを回したときと同じ)。以後のサイズ変化で位置が巻き戻ることがない。
71
+ * - 慣性は停止する (ペイン上のホイールと同じ)。
72
+ *
73
+ * @param delta - Signed pixels to scroll (positive scrolls down) / 符号付きの移動量 (正で下方向)
74
+ * @returns The applied (clamped) LOGICAL position / 適用後 (クランプ後) の論理位置
75
+ */
76
+ scrollBy: (delta: number) => number;
77
+ /**
78
+ * Applies one wheel event with the pane's own rules; returns whether it was consumed.
79
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す。
80
+ *
81
+ * 一覧の**外**に置いた要素 (列ヘッダー帯・固定フッター・空表示など) のホイールを、ペイン内と
82
+ * 寸分違わぬ意味論で流し込むための唯一の口。軸分解・速度倍率・スクロール可否・横成分の委譲先・
83
+ * 慣性停止のすべてがペイン側の 1 箇所で決まるため、消費側が規則を書き直したり食い違わせたりできない。
84
+ * 直接呼ぶより `useWheelBridge` (passive:false 登録と後始末込み) を使うこと。
85
+ */
86
+ applyWheel: (event: WheelEvent) => boolean;
44
87
  /**
45
88
  * Current LOGICAL scroll position. -1 when the pane is not connected.
46
89
  * 現在の論理スクロール位置。ペイン未接続時は -1。
@@ -198,8 +241,71 @@ export type VirtualScrollProps<T> = {
198
241
  onItemFocus?: (index: number) => void;
199
242
  scrollBarOptions?: VirtualScrollScrollBarOptions;
200
243
  behaviorOptions?: VirtualScrollBehaviorOptions;
201
- /** Delegates horizontal wheel/trackpad delta to an upstream owner (e.g. a frozen-column grid). / 横ホイール量を上流へ委譲する。 */
244
+ /** Delegates horizontal wheel/trackpad delta to an upstream owner (e.g. a frozen-column grid). / 横ホイール量を上流へ委譲する。
245
+ *
246
+ * ❗ **ホイール由来だけの口ではない。** `horizontalKeyInputs` を指定すると**キーボード由来の横量**も
247
+ * ここへ流れる。名前は 2.x で公開済みのため据え置くが、意味は「横軸が N px 動いた。横軸はあなたの所有物である」。
248
+ */
202
249
  onWheelHorizontal?: (deltaX: number) => void;
250
+ /**
251
+ * Keyboard gestures that emit a horizontal scroll delta through `onWheelHorizontal` (default: none).
252
+ * `onWheelHorizontal` へ横スクロール量を流すキーボード操作の種別 (既定: 無効)。
253
+ *
254
+ * - `[]` / 未指定 (既定): 横キーボードスクロールを行わない。
255
+ * - `["shift-arrow"]`: `Shift + ←/→` のみ。木の展開/折りたたみ (`←/→`) やグリッドのセル移動と
256
+ * 衝突しないため、既存の行 UI を持つ消費側でも安全に有効化できる。
257
+ * - `["arrow"]`: 素の `←/→` のみ。`Shift + ←/→` を選択範囲の拡張に使うグリッド向け。
258
+ * - `["arrow", "shift-arrow"]`: 両方。
259
+ *
260
+ * ❗ **配列なのは 4 状態が独立に必要だからである。** `"none" | "shift-arrows" | "arrows"` のような
261
+ * 段階的な文字列にすると `"arrows"` が `"shift-arrows"` を含んでしまい、「素の矢印だけ横スクロール、
262
+ * `Shift + ←/→` は消費側の範囲選択に残す」(Excel / データグリッドの標準) が**表現できない**。
263
+ * 同じ理由で種別配列を採るのが `pointerDragInputs` であり、本パッケージの既存の作法に揃えてある。
264
+ *
265
+ * ❗ **既定が無効なのは、行ハンドラを奪わないためである。** 本パッケージの行キーハンドラは
266
+ * capture フェーズに付くため、消費側の行 (bubble) より先に走る。既定で `←/→` を消費すると
267
+ * ツリーの展開/折りたたみのような既存操作を奪ってしまう。消費するのは `preventDefault()` のみで
268
+ * **伝播は止めない** (縦の矢印キーと同じ契約)。`stopPropagation()` は行ハンドラだけでなく
269
+ * `document` / `window` の bubble リスナーごとイベントを消し、ホットキーライブラリや
270
+ * キー入力のテレメトリまで巻き添えにするため採らない。消費側が二重動作を避ける手段は
271
+ * `defaultPrevented` の確認である。
272
+ *
273
+ * 前提: 横軸を所有するのは消費側なので、`onWheelHorizontal` が未指定なら何も起きない
274
+ * (キーイベントも消費しない)。行にフォーカスがあるときだけ働くため
275
+ * `behaviorOptions.enableKeyboardNavigation` も必要。
276
+ *
277
+ * ❗ **符号は物理キー基準** (`→` が正、`←` が負) である。横軸の向きを知っているのは消費側だけなので、
278
+ * RTL (`direction: rtl`) の一覧では消費側が受け取った値を反転すること。パッケージ側で
279
+ * `direction` を推測すると、横スクロールの実体 (CSS 変数・`scrollLeft`・transform) がどの要素の
280
+ * どの座標系かを知らないまま符号を決めることになり、当たらない前提を増やすだけになる。
281
+ *
282
+ * ❗ **働くのは行そのものにフォーカスがあるときだけである** (`event.target === event.currentTarget`)。
283
+ * 行の**中**の要素 (自作のタブ・ラジオ相当・スライダー相当など `role` だけで矢印キー操作を実装した
284
+ * ウィジェット、リンク、ネイティブの横スクロール領域) にフォーカスがある間は奪わない。
285
+ * 入力要素の allowlist だけでは `role` 実装のウィジェットを守れないため、対象そのもので判定する。
286
+ *
287
+ * 長押しの連続スクロールは **OS のキーリピート**に委ねる (`keydown` が繰り返し届く)。スクロールバーの
288
+ * 矢印ボタンが明示的なリピートタイマーを持つ (`ARROW_HOLD_DELAY` / `ARROW_HOLD_INTERVAL`) のは
289
+ * ポインタ押下にリピートが存在しないためで、キーボードとの非対称は入力機構の差に由来する。
290
+ *
291
+ * ❗ **`onWheelHorizontal` と同じ「横軸のシーム」に属するため、意図的にトップレベルに置いてある。**
292
+ * `behaviorOptions` の中へ入れると、横軸を自前で所有するラッパー (例: `@aiquants/directory-tree` は
293
+ * `Omit<VirtualScrollProps, "onWheelHorizontal">` で横軸を封じている) が `behaviorOptions` を
294
+ * そのまま素通しするため、封じたはずのシームへ横から到達できてしまう。
295
+ */
296
+ horizontalKeyInputs?: readonly ("arrow" | "shift-arrow")[];
297
+ /**
298
+ * Pixels emitted per horizontal arrow key press (default: 40, matching browser arrow scrolling).
299
+ * 横矢印キー 1 回あたりの移動量 (px。既定 40 = ブラウザの矢印スクロール相当)。
300
+ *
301
+ * ❗ **有限かつ正の値のみを受け付ける。** `0` / 負値 / `NaN` / `Infinity` を渡すと
302
+ * **キーを消費しない** (既定値へ黙って読み替えることはしない)。0 を既定へ差し替えると
303
+ * 呼び出し側の明示的な指定を握り潰し、0 のまま通すと「キーを食うのに 1px も動かない」
304
+ * 死んだ操作になる。
305
+ * 警告は `keydown` ではなく **prop の変化時に 1 回だけ**出す (キーリピート中に
306
+ * コンソールが溢れるため)。
307
+ */
308
+ horizontalKeyStep?: number;
203
309
  /**
204
310
  * ARIA / identity attributes applied to the scrollable CONTENT element — the element that
205
311
  * directly owns the rendered rows and excludes the scrollbar and overlay chrome.