@aiquants/virtualscroll 2.7.0 → 3.1.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.
@@ -148,6 +148,34 @@ export type VirtualScrollBehaviorOptions = {
148
148
  */
149
149
  pointerDragInputs?: ScrollPaneProps["pointerDragInputs"]
150
150
  enableKeyboardNavigation?: boolean
151
+ /**
152
+ * When `true`, pressing `Escape` while focus sits on an element INSIDE a row returns focus to
153
+ * the row wrapper (default: `false`).
154
+ * `true` のとき、行の**中**の要素にフォーカスがある状態で `Escape` を押すと行ラッパーへ
155
+ * フォーカスを戻す (既定 `false`)。
156
+ *
157
+ * 行はタブ順に入らない (`tabIndex={-1}`) ため、行内のリンクやウィジェットへ入った後、
158
+ * キーボードだけで行 (縦横矢印ナビゲーションの起点) へ戻る手段が既定では存在しない。
159
+ * このオプションはその復帰口をパッケージ側で提供する。
160
+ *
161
+ * ❗ **既定 OFF なのは `Escape` が最も多重負荷のキーだからである** (モーダルやドロップダウンを
162
+ * 閉じる・IME 確定取消・選択解除)。次の防御で行内ウィジェットの `Escape` を奪わない:
163
+ *
164
+ * - **bubble フェーズで聞く** (矢印キーの capture とは逆)。行内で開いたドロップダウン等が
165
+ * 自分の `Escape` を先に処理でき、`stopPropagation()` すれば本機能には届かない。
166
+ * - `event.defaultPrevented` 済みなら何もしない (preventDefault だけの消費側も尊重)。
167
+ * - IME 合成中 (`isComposing`) は何もしない (確定取消を奪わない)。
168
+ * - **編集可能なターゲット (input / textarea / select / contentEditable) では何もしない。**
169
+ * ネイティブの既定動作 (Blink / WebKit の `<input type="search">` は Escape でクリアされる) は
170
+ * `defaultPrevented` に現れないため、スクリプト先約の検査では守れない。
171
+ * - 行そのものにフォーカスがあるときは何もしない (行上の `Escape` の意味 — 選択解除など — は
172
+ * 消費側の所有物)。
173
+ *
174
+ * 作用時は `preventDefault()` のみで伝播は止めない (矢印キーと同じ契約)。復帰は
175
+ * `focusItemAtIndex` 経由なので、行が画面外なら同時にスクロールで可視化される。
176
+ * `behaviorOptions.enableKeyboardNavigation` が前提 (行ハンドラ自体が付かないため)。
177
+ */
178
+ enableEscapeRowReturn?: boolean
151
179
  wheelSpeedMultiplier?: number
152
180
  inertiaOptions?: ScrollPaneProps["inertiaOptions"]
153
181
  /**
@@ -175,6 +203,50 @@ export type VirtualScrollBehaviorOptions = {
175
203
  resetOnGetItemHeightChange?: boolean
176
204
  }
177
205
 
206
+ /**
207
+ * Range info handed to {@link VirtualScrollLiveRegionOptions.format}.
208
+ * {@link VirtualScrollLiveRegionOptions.format} へ渡す範囲情報。
209
+ */
210
+ export type VirtualScrollLiveRegionRange = {
211
+ /** Index of the first visible item / 可視先頭アイテムのインデックス */
212
+ visibleStartIndex: number
213
+ /** Index of the last visible item / 可視末尾アイテムのインデックス */
214
+ visibleEndIndex: number
215
+ /** Total item count / 総アイテム数 */
216
+ itemCount: number
217
+ }
218
+
219
+ /**
220
+ * Opt-in screen-reader live region announcing the visible range (see the `liveRegion` prop).
221
+ * 可視範囲を読み上げる opt-in のスクリーンリーダー用ライブリージョン (`liveRegion` prop 参照)。
222
+ */
223
+ export type VirtualScrollLiveRegionOptions = {
224
+ /**
225
+ * Formats the announcement text. Called after the visible range settles; returning the same
226
+ * string as last time leaves the DOM untouched (no re-announcement), returning `""` clears
227
+ * the region. The package ships NO built-in strings — wording and language are the
228
+ * consumer's (a generic package must not hardcode a locale).
229
+ * 読み上げ文言を組み立てる。可視範囲が静定した後に呼ばれ、前回と同じ文字列なら DOM を
230
+ * 触らない (再読み上げしない)。`""` を返すとリージョンを空にする。パッケージは文言を
231
+ * 一切内蔵しない — 言語と文面は消費側の所有物 (汎用パッケージはロケールをハードコード
232
+ * できない)。
233
+ *
234
+ * @param range - The settled visible range / 静定した可視範囲
235
+ * @returns Announcement text / 読み上げ文言
236
+ */
237
+ format: (range: VirtualScrollLiveRegionRange) => string
238
+ /**
239
+ * Debounce (ms) after the last range change before announcing (default: 400). Must be a
240
+ * finite number ≥ 0 — anything else logs a warning and disables announcements (no silent
241
+ * substitution). Announcing every wheel tick makes the reader chatter over the content;
242
+ * announcing too late reads a stale position. 0 announces on the next effect flush.
243
+ * 最後の範囲変化から読み上げまでのデバウンス (ms、既定 400)。有限かつ 0 以上であること —
244
+ * それ以外は警告して読み上げを無効化する (黙って既定へ読み替えない)。ホイール 1 ノッチ
245
+ * ごとに読むと内容の読み上げに被り、遅すぎると古い位置を読む。0 は次の effect で即時。
246
+ */
247
+ debounceMs?: number
248
+ }
249
+
178
250
  /**
179
251
  * Props for the VirtualScroll component.
180
252
  *
@@ -220,6 +292,24 @@ export type VirtualScrollProps<T> = {
220
292
  testId?: string
221
293
  onScroll?: (scrollPosition: number, totalHeight: number) => void
222
294
  onRangeChange?: (range: VirtualScrollRange) => void
295
+ /**
296
+ * Opt-in `aria-live` region announcing the visible range to assistive technology (default:
297
+ * none rendered). Virtualization removes off-screen rows from the DOM, so a screen-reader
298
+ * user scrolling the list otherwise gets no feedback about where they are. Provide a
299
+ * {@link VirtualScrollLiveRegionOptions.format} to opt in; the region is a visually hidden
300
+ * `role="status"` element (polite, atomic) rendered as a sibling of the pane, updated after
301
+ * the range settles ({@link VirtualScrollLiveRegionOptions.debounceMs}).
302
+ *
303
+ * 可視範囲を支援技術へ読み上げる opt-in の `aria-live` リージョン (既定: 描画しない)。
304
+ * 仮想化は画面外の行を DOM から取り除くため、スクリーンリーダーの利用者はスクロールしても
305
+ * 現在位置の手掛かりを得られない。{@link VirtualScrollLiveRegionOptions.format} を渡すと、
306
+ * 視覚的に隠した `role="status"` 要素 (polite・atomic) をペインの兄弟として描画し、範囲が
307
+ * 静定した後に更新する。
308
+ *
309
+ * ❗ **ホスト側に既存のライブリージョンがある場合は併用しないこと** (二重読み上げになる)。
310
+ * その場合は `onRangeChange` から自前のリージョンを更新する従来手段を使う。
311
+ */
312
+ liveRegion?: VirtualScrollLiveRegionOptions
223
313
  background?: ReactNode
224
314
  children: (item: T, index: number) => ReactNode
225
315
  initialScrollIndex?: number
@@ -253,15 +343,21 @@ export type VirtualScrollProps<T> = {
253
343
  * `onWheelHorizontal` へ横スクロール量を流すキーボード操作の種別 (既定: 無効)。
254
344
  *
255
345
  * - `[]` / 未指定 (既定): 横キーボードスクロールを行わない。
256
- * - `["shift-arrow"]`: `Shift + ←/→` のみ。木の展開/折りたたみ (`←/→`) やグリッドのセル移動と
257
- * 衝突しないため、既存の行 UI を持つ消費側でも安全に有効化できる。
258
- * - `["arrow"]`: 素の `←/→` のみ。`Shift + ←/→` を選択範囲の拡張に使うグリッド向け。
259
- * - `["arrow", "shift-arrow"]`: 両方。
346
+ * - `["arrow"]`: 素の `←/→` のみ。
347
+ *
348
+ * **`Shift + ←/→` は常に消費しない (3.0.0 で `"shift-arrow"` 入力を撤去)。** ブラウザ標準では
349
+ * Shift+矢印は選択範囲の伸縮であり、横取りすると行内テキストを選び直す手段がキーボードから消える。
350
+ * 2.x では「選択があるときだけ譲る」検出 (`getSelection` の交差判定) で共存させていたが、
351
+ * (a) Shadow DOM 内では選択が可搬に検出できない (Chromium の `window.getSelection()` は shadow root
352
+ * 内部を見せず、内部選択は非標準 API でしか取れない)、(b) `Ctrl+A` の全選択では全行が選択と交差して
353
+ * 横キーボードスクロールが選択解除まで全面ロックアウトする、という構造的欠陥が残った。検出の
354
+ * 精度を上げる案はどれも別の正しいユーザー意図を誤判定するため、機能ごと撤去して Shift 側を
355
+ * 無条件でブラウザへ返す。横スクロールの Shift 系入力が必要なら `shift+ホイール` が引き続き使える
356
+ * (`resolveWheelAxes` の軸規則。こちらは選択と衝突しない)。
260
357
  *
261
- * ❗ **配列なのは 4 状態が独立に必要だからである。** `"none" | "shift-arrows" | "arrows"` のような
262
- * 段階的な文字列にすると `"arrows"` `"shift-arrows"` を含んでしまい、「素の矢印だけ横スクロール、
263
- * `Shift + ←/→` は消費側の範囲選択に残す」(Excel / データグリッドの標準) が**表現できない**。
264
- * 同じ理由で種別配列を採るのが `pointerDragInputs` であり、本パッケージの既存の作法に揃えてある。
358
+ * ❗ **語彙が 1 つでも配列なのは意図的である。** 種別配列は `pointerDragInputs` と同じ本パッケージの
359
+ * 作法であり、将来の入力種別追加が型の破壊なしにできる。boolean へ畳むと prop 名が変わり、横軸を
360
+ * `Omit<>` で封じているラッパー (例: `@aiquants/directory-tree`) の封印リストまで連鎖破壊する。
265
361
  *
266
362
  * ❗ **既定が無効なのは、行ハンドラを奪わないためである。** 本パッケージの行キーハンドラは
267
363
  * capture フェーズに付くため、消費側の行 (bubble) より先に走る。既定で `←/→` を消費すると
@@ -294,7 +390,7 @@ export type VirtualScrollProps<T> = {
294
390
  * `Omit<VirtualScrollProps, "onWheelHorizontal">` で横軸を封じている) が `behaviorOptions` を
295
391
  * そのまま素通しするため、封じたはずのシームへ横から到達できてしまう。
296
392
  */
297
- horizontalKeyInputs?: readonly ("arrow" | "shift-arrow")[]
393
+ horizontalKeyInputs?: readonly "arrow"[]
298
394
  /**
299
395
  * Pixels emitted per horizontal arrow key press (default: 40, matching browser arrow scrolling).
300
396
  * 横矢印キー 1 回あたりの移動量 (px。既定 40 = ブラウザの矢印スクロール相当)。
@@ -390,32 +486,6 @@ const ANCHOR_REBASE_DISTANCE = 1_048_576 // 2^20 px
390
486
  */
391
487
  const DEFAULT_HORIZONTAL_KEY_STEP = 40
392
488
 
393
- /**
394
- * Reports whether a non-collapsed text selection currently sits inside the given element.
395
- * 指定要素の中に折り畳まれていないテキスト選択が存在するかを返す処理。
396
- *
397
- * `Shift + ←/→` はブラウザ標準では選択範囲の伸縮である。行の中でテキストを選んでいる最中に
398
- * 横スクロールへ横取りすると、**選び直す手段がキーボードから消える**。選択が空 (キャレットだけ) の
399
- * ときは伸縮の起点が無いため横取りしてよい。
400
- *
401
- * @param element - Element to test the selection against / 選択範囲の所在を調べる要素
402
- * @returns True when a non-collapsed selection is inside the element / 折り畳まれていない選択が内側にある場合に true
403
- */
404
- const hasTextSelectionWithin = (element: HTMLElement): boolean => {
405
- const selection = element.ownerDocument.defaultView?.getSelection()
406
- if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
407
- return false
408
- }
409
- // ❗ 起点 (anchorNode) の包含では不十分。`Ctrl+A` のようにページ全体を選ぶと起点は行の外に落ち、
410
- // 「行の上に見えている選択」を取りこぼす (実ブラウザで実測)。範囲が行と**交差**するかで判定する。
411
- for (let index = 0; index < selection.rangeCount; index += 1) {
412
- if (selection.getRangeAt(index).intersectsNode(element)) {
413
- return true
414
- }
415
- }
416
- return false
417
- }
418
-
419
489
  /**
420
490
  * Converts a numeric size into a non-negative bigint for large collection handling.
421
491
  *
@@ -842,6 +912,15 @@ type VirtualScrollItemProps<T> = {
842
912
  clipItemHeight: boolean
843
913
  enableKeyboardNavigation: boolean
844
914
  onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>, index: number) => void
915
+ /**
916
+ * Bubble-phase Escape handler for the row-return feature (undefined when disabled).
917
+ * 行復帰機能用の bubble フェーズ Escape ハンドラ (機能無効時は undefined)。
918
+ *
919
+ * ❗ 矢印キー (`onKeyDown` = capture) と**逆のフェーズ**なのが本質: capture で聞くと行内で
920
+ * 開いたドロップダウン等より先に Escape を奪ってしまう。bubble なら行内ウィジェットが
921
+ * `stopPropagation()` / `preventDefault()` で先約を主張できる。
922
+ */
923
+ onEscapeKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>, index: number) => void
845
924
  onFocus: (index: number) => void
846
925
  registerItemRef: (index: number, node: HTMLDivElement | null) => void
847
926
  }
@@ -855,15 +934,20 @@ type VirtualScrollItemProps<T> = {
855
934
  * ユーザーが提供したアイテムコンポーネントを、配置スタイルとイベントハンドラでラップします。
856
935
  * Props が変更されていない場合の不要な再レンダリングを防ぐためにメモ化されています。
857
936
  */
858
- const VirtualScrollItem = React.memo(<T,>({ index, top, height, item, children, clipItemHeight, enableKeyboardNavigation, onKeyDown, onFocus, registerItemRef }: VirtualScrollItemProps<T>) => {
937
+ const VirtualScrollItem = React.memo(<T,>({ index, top, height, item, children, clipItemHeight, enableKeyboardNavigation, onKeyDown, onEscapeKeyDown, onFocus, registerItemRef }: VirtualScrollItemProps<T>) => {
859
938
  const handleRef = useCallback((node: HTMLDivElement | null) => registerItemRef(index, node), [index, registerItemRef])
860
939
  const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => onKeyDown(e, index), [index, onKeyDown])
940
+ const handleEscapeKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => onEscapeKeyDown?.(e, index), [index, onEscapeKeyDown])
861
941
  const handleFocus = useCallback(() => onFocus(index), [index, onFocus])
862
942
  const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
863
943
  e.currentTarget.focus({ preventScroll: true })
864
944
  }, [])
865
945
 
866
946
  return (
947
+ // 行ラッパーの role は消費側の複合ウィジェット契約 (listbox / tree / grid — README「Composite widget
948
+ // roles」) の所有物で、パッケージが既定 role を課すと衝突する。キー/フォーカス結線は
949
+ // enableKeyboardNavigation の行ナビゲーション機構そのもの。
950
+ // biome-ignore lint/a11y/noStaticElementInteractions: role は消費側の複合ウィジェット契約の所有物 (上記)
867
951
  <div
868
952
  ref={handleRef}
869
953
  data-index={index}
@@ -877,6 +961,7 @@ const VirtualScrollItem = React.memo(<T,>({ index, top, height, item, children,
877
961
  tabIndex={enableKeyboardNavigation ? -1 : undefined}
878
962
  onPointerDown={enableKeyboardNavigation ? handlePointerDown : undefined}
879
963
  onKeyDownCapture={enableKeyboardNavigation ? handleKeyDown : undefined}
964
+ onKeyDown={enableKeyboardNavigation && onEscapeKeyDown ? handleEscapeKeyDown : undefined}
880
965
  onFocusCapture={enableKeyboardNavigation ? handleFocus : undefined}>
881
966
  {children(item, index)}
882
967
  </div>
@@ -914,12 +999,13 @@ const VirtualScrollInner = <T,>(
914
999
  horizontalKeyInputs,
915
1000
  horizontalKeyStep = DEFAULT_HORIZONTAL_KEY_STEP,
916
1001
  contentProps,
1002
+ liveRegion,
917
1003
  }: VirtualScrollProps<T>,
918
1004
  ref: React.Ref<VirtualScrollHandle>,
919
1005
  ) => {
920
1006
  const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
921
1007
 
922
- const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
1008
+ const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, enableEscapeRowReturn = false, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
923
1009
 
924
1010
  // viewportSize 未指定のときは ScrollPane が自分の帯を計測し、その値をこのコールバックで通知する。
925
1011
  // 描画枚数 (computeRenderingRanges) とアライン計算 (scrollToIndex / ドリフト補正) はこの解決済み値を使う。
@@ -1915,14 +2001,14 @@ const VirtualScrollInner = <T,>(
1915
2001
  if (!emitHorizontal) {
1916
2002
  return
1917
2003
  }
1918
- // 押されたジェスチャが許可種別に含まれるかを確認する
1919
- const gesture = event.shiftKey ? "shift-arrow" : "arrow"
1920
- if (!horizontalKeyInputs?.includes(gesture)) {
2004
+ // ❗ Shift 併用は無条件で消費しない (3.0.0 で "shift-arrow" 入力を撤去)。ブラウザ標準では
2005
+ // 選択範囲の伸縮であり、選択の有無での条件分岐はしない 選択検出は Shadow DOM
2006
+ // 可搬に成立せず、Ctrl+A 全選択では全行が交差して解除まで全面ロックアウトするため
2007
+ if (event.shiftKey) {
1921
2008
  return
1922
2009
  }
1923
- // ❗ テキスト選択中の Shift+←/→ は選択範囲の伸縮であり、横スクロールで奪ってはならない
1924
- // (実ブラウザで実測: 奪うと行内のテキストを選び直せなくなる)
1925
- if (event.shiftKey && hasTextSelectionWithin(event.currentTarget)) {
2010
+ // 押されたジェスチャが許可種別に含まれるかを確認する
2011
+ if (!horizontalKeyInputs?.includes("arrow")) {
1926
2012
  return
1927
2013
  }
1928
2014
  // ❗ 不正な移動量は既定へ黙って読み替えず、消費もしない (Strict No-Fallback)。
@@ -1976,6 +2062,47 @@ const VirtualScrollInner = <T,>(
1976
2062
  [enableKeyboardNavigation, itemCount, focusItemAtIndex, horizontalKeyInputs, horizontalKeyStep],
1977
2063
  )
1978
2064
 
2065
+ const handleItemEscapeReturn = useCallback(
2066
+ /**
2067
+ * Returns focus from an element inside the row back to the row wrapper on Escape.
2068
+ * 行内要素の Escape で行ラッパーへフォーカスを戻す処理。
2069
+ *
2070
+ * @param event - Keyboard event (bubble phase on the row wrapper) / 行ラッパー bubble フェーズのキーイベント
2071
+ * @param index - Row index / 行インデックス
2072
+ */
2073
+ (event: React.KeyboardEvent<HTMLDivElement>, index: number) => {
2074
+ if (event.key !== "Escape") {
2075
+ return
2076
+ }
2077
+ // 行内ウィジェット (ドロップダウン等) が先に消費した Escape は尊重する。
2078
+ // bubble フェーズなのでウィジェット側の preventDefault はここで必ず観測できる
2079
+ // (stopPropagation されていればそもそもこのハンドラに届かない)
2080
+ if (event.defaultPrevented) {
2081
+ return
2082
+ }
2083
+ // IME 合成中の Escape は確定取消であり、フォーカス移動で奪ってはならない
2084
+ if (event.nativeEvent.isComposing) {
2085
+ return
2086
+ }
2087
+ // 行そのものにフォーカスがあるときの Escape の意味 (選択解除など) は消費側の所有物
2088
+ if (event.target === event.currentTarget) {
2089
+ return
2090
+ }
2091
+ // ❗ 編集可能なターゲット (input / textarea / select / contentEditable) の Escape は奪わない。
2092
+ // ネイティブの既定動作 (例: Blink/WebKit は <input type="search"> を Escape でクリアする) は
2093
+ // defaultPrevented に**現れない**ため、上のスクリプト先約の検査では守れない — ここで
2094
+ // preventDefault すると検索欄のクリアを黙殺したうえ編集途中のフォーカスまで奪う (実 Chromium で実証)
2095
+ const target = event.target as HTMLElement
2096
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target.isContentEditable) {
2097
+ return
2098
+ }
2099
+ // 消費の作法は矢印キーと同じ: preventDefault のみで伝播は止めない
2100
+ event.preventDefault()
2101
+ focusItemAtIndex(index)
2102
+ },
2103
+ [focusItemAtIndex],
2104
+ )
2105
+
1979
2106
  const handleItemFocus = useCallback(
1980
2107
  (index: number) => {
1981
2108
  if (!enableKeyboardNavigation) {
@@ -2177,6 +2304,7 @@ const VirtualScrollInner = <T,>(
2177
2304
  clipItemHeight={clipItemHeight}
2178
2305
  enableKeyboardNavigation={enableKeyboardNavigation}
2179
2306
  onKeyDown={handleItemKeyDown}
2307
+ onEscapeKeyDown={enableEscapeRowReturn ? handleItemEscapeReturn : undefined}
2180
2308
  onFocus={handleItemFocus}
2181
2309
  registerItemRef={registerItemRef}>
2182
2310
  {children}
@@ -2240,6 +2368,8 @@ const VirtualScrollInner = <T,>(
2240
2368
  getItemHeight,
2241
2369
  handleItemFocus,
2242
2370
  handleItemKeyDown,
2371
+ enableEscapeRowReturn,
2372
+ handleItemEscapeReturn,
2243
2373
  issueCompensationScroll,
2244
2374
  registerItemRef,
2245
2375
  renderingEndIndex,
@@ -2331,6 +2461,53 @@ const VirtualScrollInner = <T,>(
2331
2461
  currentRangeRef.current = currentRange
2332
2462
  }, [currentRange])
2333
2463
 
2464
+ // ライブリージョンの読み上げ文言。liveRegion 未指定なら常に空のまま (リージョン自体を描画しない)
2465
+ const [liveAnnouncement, setLiveAnnouncement] = useState("")
2466
+ // format は消費側がインラインで渡すのが普通なので ref 越しに読む (identity 変化でデバウンスを
2467
+ // 巻き戻さない — 巻き戻すと親の毎レンダーでタイマーが再スタートし、永遠に読み上げない)
2468
+ const liveRegionRef = useRef(liveRegion)
2469
+ useLayoutEffect(() => {
2470
+ liveRegionRef.current = liveRegion
2471
+ }, [liveRegion])
2472
+ const liveRegionDebounceMs = liveRegion?.debounceMs ?? 400
2473
+ const isLiveRegionDebounceValid = Number.isFinite(liveRegionDebounceMs) && liveRegionDebounceMs >= 0
2474
+ const hasLiveRegion = liveRegion !== undefined
2475
+
2476
+ useEffect(() => {
2477
+ // 目的: 不正な debounceMs を黙って既定へ読み替えず、機能を無効化したうえで 1 回だけ警告する。
2478
+ // 依存関係: hasLiveRegion, isLiveRegionDebounceValid, liveRegionDebounceMs
2479
+ if (hasLiveRegion && !isLiveRegionDebounceValid) {
2480
+ Logger.warn(`[VirtualScroll] liveRegion.debounceMs must be a finite number >= 0, received ${liveRegionDebounceMs}. Announcements are disabled.`)
2481
+ }
2482
+ }, [hasLiveRegion, isLiveRegionDebounceValid, liveRegionDebounceMs])
2483
+
2484
+ useEffect(() => {
2485
+ // 目的: 可視範囲の静定後にライブリージョンの文言を更新する (trailing デバウンス)。
2486
+ // 範囲が動くたびにこの effect が張り直され、タイマーが再スタートする = 最後の変化から
2487
+ // debounceMs 後に 1 回だけ発火する。
2488
+ // 依存関係: hasLiveRegion, isLiveRegionDebounceValid, liveRegionDebounceMs,
2489
+ // visibleStartIndex, visibleEndIndex, itemCount
2490
+ if (!(hasLiveRegion && isLiveRegionDebounceValid)) {
2491
+ // ❗ 無効化時は残留文言を消す。消さないと、prop を外して再度付けたとき (実行時トグル) に
2492
+ // 古い範囲の文言が role="status" 要素ごと同期的に再挿入され、新規挿入されたライブ
2493
+ // リージョンを内容ごと読み上げる AT が**過去の位置**を読む。同値なら React が bail out
2494
+ // するため、通常レンダーでのコストは無い
2495
+ setLiveAnnouncement("")
2496
+ return
2497
+ }
2498
+ const timerId = setTimeout(() => {
2499
+ const format = liveRegionRef.current?.format
2500
+ if (!format) {
2501
+ return
2502
+ }
2503
+ const text = format({ visibleStartIndex, visibleEndIndex, itemCount })
2504
+ // 同一文言の再 set は React 自身が bail out する (Object.is 一致で再レンダーしない) ため、
2505
+ // ここで比較ガードを持つ必要は無い — 持っても等価変異になり検証不能な飾りになるだけ
2506
+ setLiveAnnouncement(text)
2507
+ }, liveRegionDebounceMs)
2508
+ return () => clearTimeout(timerId)
2509
+ }, [hasLiveRegion, isLiveRegionDebounceValid, liveRegionDebounceMs, visibleStartIndex, visibleEndIndex, itemCount])
2510
+
2334
2511
  useImperativeHandle(
2335
2512
  ref,
2336
2513
  () => ({
@@ -2358,7 +2535,8 @@ const VirtualScrollInner = <T,>(
2358
2535
 
2359
2536
  const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
2360
2537
 
2361
- return (
2538
+ // ライブリージョン無効時はペインをそのまま根要素として返す (既存消費者の DOM 形状を変えない)
2539
+ const pane = (
2362
2540
  <ScrollPane
2363
2541
  ref={scrollPaneRef}
2364
2542
  contentSize={totalContentHeight}
@@ -2390,6 +2568,21 @@ const VirtualScrollInner = <T,>(
2390
2568
  {renderVisibleItems}
2391
2569
  </ScrollPane>
2392
2570
  )
2571
+
2572
+ if (!hasLiveRegion) {
2573
+ return pane
2574
+ }
2575
+ return (
2576
+ <>
2577
+ {pane}
2578
+ {/* ペインの**兄弟**として描画する: ペイン内部 (スクロール座標系) に置くと transform 配下の
2579
+ aria-live を無視する AT があり、ペインの子構成 (行 / 背景 / オーバーレイ) の契約も汚す。
2580
+ 視覚的には .aqvs-live-region (絶対配置 1px クリップ) で完全に隠れ、レイアウトに影響しない */}
2581
+ <div className="aqvs-live-region" role="status" aria-live="polite" aria-atomic="true" data-aqvs-live-region="">
2582
+ {liveAnnouncement}
2583
+ </div>
2584
+ </>
2585
+ )
2393
2586
  }
2394
2587
 
2395
2588
  /**
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * 可変なアイテム高さに対応したReact用の高性能仮想スクロールコンポーネント。
6
6
  */
7
7
 
8
+ export { createResidualQuantizer, type ResidualQuantizer, type ResidualQuantizerOptions } from "./residualQuantizer.ts"
8
9
  export { computeAutoTapScrollMaxSpeedMultiplier, computeTapScrollSpeed, ScrollBar, type ScrollBarProps, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_SPEED_DEFAULTS, type TapScrollSpeedInput } from "./ScrollBar.tsx"
9
10
  export { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneInertiaOptions, type ScrollPaneProps } from "./ScrollPane.tsx"
10
11
  export type { TapScrollCircleRenderProps } from "./TapScrollCircle.tsx"
@@ -18,6 +19,8 @@ export {
18
19
  VirtualScroll,
19
20
  type VirtualScrollBehaviorOptions,
20
21
  type VirtualScrollHandle,
22
+ type VirtualScrollLiveRegionOptions,
23
+ type VirtualScrollLiveRegionRange,
21
24
  type VirtualScrollProps,
22
25
  type VirtualScrollRange,
23
26
  type VirtualScrollScrollBarOptions,
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @module residualQuantizer
3
+ * @description Residual-carrying delta quantizer for consumers that snap scroll positions to a
4
+ * fixed quantum (row height, column width). High-resolution trackpads emit many sub-quantum
5
+ * deltas; a consumer that rounds each delta independently rounds every one of them to zero and
6
+ * the list never moves. This accumulator carries the sub-quantum remainder across events and
7
+ * emits whole quanta as soon as they accumulate.
8
+ *
9
+ * @description スクロール位置を固定の量子 (行高・列幅) へスナップする消費側のための、
10
+ * 残差持ち越し型のデルタ量子化器。高解像度トラックパッドは 1 量子未満のデルタを大量に発行するため、
11
+ * イベントごとに独立して丸める消費側では**すべてが 0 に丸められ、ゆっくり撫でると 1px も動かない**。
12
+ * 本量子化器は量子未満の余りをイベントを跨いで持ち越し、貯まった分だけ量子単位で放出する。
13
+ *
14
+ * ❗ **時間は扱わない (意図的)。** アイドルタイムアウトでの残差破棄は「いつ捨てるか」の方針が
15
+ * 消費側の UI ごとに異なり、内蔵すると幽霊スクロール (古い残差が後から発火) か不感帯のどちらかを
16
+ * パッケージが押し付けることになる。時間起点の破棄が要る消費側は、自前のタイマーから
17
+ * {@link ResidualQuantizer.reset} を呼ぶこと。既定の防御は**方向反転での残差破棄**のみで、
18
+ * これは時間に依存せず「逆へ動かし始めたのに古い残差が先に順方向へ発火する」事故だけを塞ぐ。
19
+ */
20
+
21
+ /**
22
+ * Options for {@link createResidualQuantizer}.
23
+ * {@link createResidualQuantizer} のオプション。
24
+ */
25
+ export type ResidualQuantizerOptions = {
26
+ /**
27
+ * Quantum (px) each emission is a multiple of. Must be finite and positive — anything else
28
+ * throws at creation time (fail fast; a silent default would hide the caller's bug).
29
+ * 放出量の単位となる量子 (px)。有限かつ正であること — それ以外は生成時に throw する
30
+ * (Fail Fast。既定値へ黙って読み替えると呼び出し側のバグを隠す)。
31
+ */
32
+ quantum: number
33
+ /**
34
+ * Drop the carried residue when the incoming delta's direction opposes it (default: `true`).
35
+ * Without this, scrolling +20px (quantum 24, nothing emitted) and then reversing emits the
36
+ * first reverse quantum "late" — the stale forward residue eats part of the reverse motion.
37
+ * 入力デルタの向きが持ち越し残差と逆のとき残差を捨てる (既定 `true`)。捨てないと、
38
+ * +20px (量子 24 で未放出) の後に反転したとき、古い順方向残差が逆方向の動きを食い、
39
+ * 最初の逆方向量子の発火が「遅れて」体感される。
40
+ */
41
+ resetOnDirectionChange?: boolean
42
+ }
43
+
44
+ /**
45
+ * A quantizer instance. One instance per quantized axis — sharing one across axes mixes residues.
46
+ * 量子化器インスタンス。量子化する軸ごとに 1 つ持つこと — 軸間で共有すると残差が混線する。
47
+ */
48
+ export type ResidualQuantizer = {
49
+ /**
50
+ * Accumulates `delta` and returns the emission: a multiple of the quantum (possibly 0),
51
+ * signed toward the accumulated direction. Non-finite deltas AND deltas whose magnitude
52
+ * exceeds `Number.MAX_SAFE_INTEGER` emit 0 and leave the residue untouched — beyond 2^53
53
+ * the float product stops being an EXACT multiple of the quantum, which is precisely the
54
+ * property a grid-snapping consumer relies on.
55
+ * `delta` を蓄積し、放出量 (量子の倍数。0 のこともある) を蓄積方向の符号で返す。
56
+ * 非有限のデルタ、および絶対値が `Number.MAX_SAFE_INTEGER` を超えるデルタは 0 を返し
57
+ * 残差を汚さない — 2^53 超では浮動小数点積が量子の**正確な**倍数でなくなり、グリッド
58
+ * スナップ消費側が依拠する性質そのものが壊れるため。
59
+ *
60
+ * @param delta - Incoming delta (px) / 入力デルタ (px)
61
+ * @returns Emitted amount (multiple of the quantum) / 放出量 (量子の倍数)
62
+ */
63
+ push: (delta: number) => number
64
+ /**
65
+ * Returns the carried residue without mutating it (|residue| < quantum).
66
+ * 持ち越し中の残差を変更せずに返す (|残差| < 量子)。
67
+ *
68
+ * @returns Current residue (px) / 現在の残差 (px)
69
+ */
70
+ peekResidue: () => number
71
+ /**
72
+ * Clears the residue. Call from consumer-side idle timers, on pointer leave, or when the
73
+ * quantized target (row/column layout) changes under the accumulator.
74
+ * 残差を破棄する。消費側のアイドルタイマー・ポインタ離脱・量子化対象 (行/列レイアウト) の
75
+ * 変更時に呼ぶ。
76
+ */
77
+ reset: () => void
78
+ }
79
+
80
+ /**
81
+ * Creates a residual-carrying delta quantizer.
82
+ * 残差持ち越し型のデルタ量子化器を生成する処理。
83
+ *
84
+ * ```ts
85
+ * const quantizer = createResidualQuantizer({ quantum: rowHeight })
86
+ * const handleWheelVertical = (deltaY: number) => {
87
+ * const emitted = quantizer.push(deltaY)
88
+ * if (emitted !== 0) {
89
+ * snapScrollBy(emitted) // 常に量子の倍数
90
+ * }
91
+ * }
92
+ * ```
93
+ *
94
+ * @param options - Quantizer options / 量子化器のオプション
95
+ * @returns A new quantizer instance / 新しい量子化器インスタンス
96
+ * @throws {TypeError} When `quantum` is not a finite positive number / `quantum` が有限の正数でない場合
97
+ */
98
+ export const createResidualQuantizer = (options: ResidualQuantizerOptions): ResidualQuantizer => {
99
+ const { quantum, resetOnDirectionChange = true } = options
100
+ // Fail Fast: 不正な量子は生成時に拒否する (押すたび NaN を配る量子化器を作らせない)
101
+ if (!(Number.isFinite(quantum) && quantum > 0)) {
102
+ throw new TypeError(`[createResidualQuantizer] quantum must be a finite positive number, received ${quantum}`)
103
+ }
104
+
105
+ let residue = 0
106
+
107
+ return {
108
+ push: (delta: number): number => {
109
+ // 非有限デルタは残差を汚さず 0 を返す (蓄積器に NaN が入ると以後全放出が死ぬ)。
110
+ // ❗ |delta| > Number.MAX_SAFE_INTEGER も同じ契約で見送る: 2^53 超では
111
+ // trunc(residue / quantum) * quantum が浮動小数点丸めで「量子の正確な倍数」で
112
+ // なくなり、グリッドスナップ消費側へ桁ずれした放出を渡してしまう (実測:
113
+ // quantum 3 に対し 1e17 % 3 === 1)。残差は常に |残差| < 量子で汚染されない
114
+ if (!Number.isFinite(delta) || Math.abs(delta) > Number.MAX_SAFE_INTEGER) {
115
+ return 0
116
+ }
117
+ // 方向反転で古い残差を捨てる (逆方向の動き出しを古い順方向残差に食わせない)
118
+ if (resetOnDirectionChange && residue !== 0 && delta !== 0 && Math.sign(delta) !== Math.sign(residue)) {
119
+ residue = 0
120
+ }
121
+ residue += delta
122
+ // trunc は 0 方向への切り捨てなので、正負どちらの蓄積でも「量子に満たない分」が残差に残る。
123
+ // ❗ 末尾の + 0 は -0 の正規化 (負方向の空放出は -0 になり、Object.is 比較や表示を汚す)
124
+ const emitted = Math.trunc(residue / quantum) * quantum + 0
125
+ residue -= emitted
126
+ return emitted
127
+ },
128
+ peekResidue: () => residue,
129
+ reset: () => {
130
+ residue = 0
131
+ },
132
+ }
133
+ }
@@ -333,3 +333,18 @@
333
333
  position: absolute;
334
334
  width: 100%;
335
335
  }
336
+
337
+ /* 支援技術専用ライブリージョン (liveRegion prop)。標準の visually-hidden パターン:
338
+ 絶対配置 + 1px クリップで視覚とレイアウトから完全に消しつつ、AT からは読める。
339
+ display:none / visibility:hidden にすると aria-live ごと無効化されるため使えない */
340
+ .aqvs-live-region {
341
+ position: absolute;
342
+ width: 1px;
343
+ height: 1px;
344
+ margin: -1px;
345
+ padding: 0;
346
+ border: 0;
347
+ overflow: hidden;
348
+ clip-path: inset(50%);
349
+ white-space: nowrap;
350
+ }