@aiquants/virtualscroll 3.0.0 → 3.1.1

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
@@ -822,6 +912,15 @@ type VirtualScrollItemProps<T> = {
822
912
  clipItemHeight: boolean
823
913
  enableKeyboardNavigation: boolean
824
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
825
924
  onFocus: (index: number) => void
826
925
  registerItemRef: (index: number, node: HTMLDivElement | null) => void
827
926
  }
@@ -835,15 +934,20 @@ type VirtualScrollItemProps<T> = {
835
934
  * ユーザーが提供したアイテムコンポーネントを、配置スタイルとイベントハンドラでラップします。
836
935
  * Props が変更されていない場合の不要な再レンダリングを防ぐためにメモ化されています。
837
936
  */
838
- 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>) => {
839
938
  const handleRef = useCallback((node: HTMLDivElement | null) => registerItemRef(index, node), [index, registerItemRef])
840
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])
841
941
  const handleFocus = useCallback(() => onFocus(index), [index, onFocus])
842
942
  const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
843
943
  e.currentTarget.focus({ preventScroll: true })
844
944
  }, [])
845
945
 
846
946
  return (
947
+ // 行ラッパーの role は消費側の複合ウィジェット契約 (listbox / tree / grid — README「Composite widget
948
+ // roles」) の所有物で、パッケージが既定 role を課すと衝突する。キー/フォーカス結線は
949
+ // enableKeyboardNavigation の行ナビゲーション機構そのもの。
950
+ // biome-ignore lint/a11y/noStaticElementInteractions: role は消費側の複合ウィジェット契約の所有物 (上記)
847
951
  <div
848
952
  ref={handleRef}
849
953
  data-index={index}
@@ -857,6 +961,7 @@ const VirtualScrollItem = React.memo(<T,>({ index, top, height, item, children,
857
961
  tabIndex={enableKeyboardNavigation ? -1 : undefined}
858
962
  onPointerDown={enableKeyboardNavigation ? handlePointerDown : undefined}
859
963
  onKeyDownCapture={enableKeyboardNavigation ? handleKeyDown : undefined}
964
+ onKeyDown={enableKeyboardNavigation && onEscapeKeyDown ? handleEscapeKeyDown : undefined}
860
965
  onFocusCapture={enableKeyboardNavigation ? handleFocus : undefined}>
861
966
  {children(item, index)}
862
967
  </div>
@@ -894,12 +999,13 @@ const VirtualScrollInner = <T,>(
894
999
  horizontalKeyInputs,
895
1000
  horizontalKeyStep = DEFAULT_HORIZONTAL_KEY_STEP,
896
1001
  contentProps,
1002
+ liveRegion,
897
1003
  }: VirtualScrollProps<T>,
898
1004
  ref: React.Ref<VirtualScrollHandle>,
899
1005
  ) => {
900
1006
  const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
901
1007
 
902
- 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 ?? {}
903
1009
 
904
1010
  // viewportSize 未指定のときは ScrollPane が自分の帯を計測し、その値をこのコールバックで通知する。
905
1011
  // 描画枚数 (computeRenderingRanges) とアライン計算 (scrollToIndex / ドリフト補正) はこの解決済み値を使う。
@@ -1956,6 +2062,47 @@ const VirtualScrollInner = <T,>(
1956
2062
  [enableKeyboardNavigation, itemCount, focusItemAtIndex, horizontalKeyInputs, horizontalKeyStep],
1957
2063
  )
1958
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
+
1959
2106
  const handleItemFocus = useCallback(
1960
2107
  (index: number) => {
1961
2108
  if (!enableKeyboardNavigation) {
@@ -2157,6 +2304,7 @@ const VirtualScrollInner = <T,>(
2157
2304
  clipItemHeight={clipItemHeight}
2158
2305
  enableKeyboardNavigation={enableKeyboardNavigation}
2159
2306
  onKeyDown={handleItemKeyDown}
2307
+ onEscapeKeyDown={enableEscapeRowReturn ? handleItemEscapeReturn : undefined}
2160
2308
  onFocus={handleItemFocus}
2161
2309
  registerItemRef={registerItemRef}>
2162
2310
  {children}
@@ -2220,6 +2368,8 @@ const VirtualScrollInner = <T,>(
2220
2368
  getItemHeight,
2221
2369
  handleItemFocus,
2222
2370
  handleItemKeyDown,
2371
+ enableEscapeRowReturn,
2372
+ handleItemEscapeReturn,
2223
2373
  issueCompensationScroll,
2224
2374
  registerItemRef,
2225
2375
  renderingEndIndex,
@@ -2311,6 +2461,53 @@ const VirtualScrollInner = <T,>(
2311
2461
  currentRangeRef.current = currentRange
2312
2462
  }, [currentRange])
2313
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
+
2314
2511
  useImperativeHandle(
2315
2512
  ref,
2316
2513
  () => ({
@@ -2338,7 +2535,8 @@ const VirtualScrollInner = <T,>(
2338
2535
 
2339
2536
  const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
2340
2537
 
2341
- return (
2538
+ // ライブリージョン無効時はペインをそのまま根要素として返す (既存消費者の DOM 形状を変えない)
2539
+ const pane = (
2342
2540
  <ScrollPane
2343
2541
  ref={scrollPaneRef}
2344
2542
  contentSize={totalContentHeight}
@@ -2370,6 +2568,21 @@ const VirtualScrollInner = <T,>(
2370
2568
  {renderVisibleItems}
2371
2569
  </ScrollPane>
2372
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
+ )
2373
2586
  }
2374
2587
 
2375
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
+ }
package/src/utils.ts CHANGED
@@ -14,3 +14,45 @@
14
14
  export const minmax = (value: number, min: number, max: number): number => {
15
15
  return Math.max(min, Math.min(max, value))
16
16
  }
17
+
18
+ /**
19
+ * Computes an element's CSS transform scale along one axis (visual px / layout px).
20
+ *
21
+ * 要素の指定軸方向 CSS transform スケール (視覚px / レイアウトpx) を算出。
22
+ * 祖先に transform: scale(...) があると client 座標 (視覚px) がレイアウトpx とズレるため、
23
+ * その比率でドラッグ量・クリック座標を補正する。分母には整数丸めされる offsetWidth/offsetHeight
24
+ * ではなく、transform の影響を受けず小数精度を持つ getComputedStyle のレイアウト寸法
25
+ * (content + padding + border) を優先して使い、transform なしなら厳密に 1 を返す。
26
+ * 取得不能時 (jsdom 等) は offset 寸法へフォールバックし、それも 0 なら 1 を返す。
27
+ *
28
+ * @param element The element to measure. / 計測対象要素。
29
+ * @param axis The axis to measure along ("x" = width, "y" = height). / 計測軸。
30
+ * @returns The visual/layout scale ratio (1 when unmeasurable). / 視覚/レイアウト比 (計測不能時 1)。
31
+ */
32
+ export const getAxisScale = (element: HTMLElement, axis: "x" | "y"): number => {
33
+ const isX = axis === "x"
34
+ const rect = element.getBoundingClientRect()
35
+ const visualSize = isX ? rect.width : rect.height
36
+ // computed style の値 (px 文字列) を数値化する。解釈不能なら 0 とみなす。
37
+ const parseSize = (value: string) => {
38
+ const parsed = Number.parseFloat(value)
39
+ return Number.isFinite(parsed) ? parsed : 0
40
+ }
41
+ const computed = window.getComputedStyle(element)
42
+ const contentSize = parseSize(isX ? computed.width : computed.height)
43
+ // rect (border-box) と同系になるよう content + padding + border を合成する。
44
+ const computedLayoutSize =
45
+ contentSize > 0
46
+ ? contentSize +
47
+ parseSize(isX ? computed.paddingLeft : computed.paddingTop) +
48
+ parseSize(isX ? computed.paddingRight : computed.paddingBottom) +
49
+ parseSize(isX ? computed.borderLeftWidth : computed.borderTopWidth) +
50
+ parseSize(isX ? computed.borderRightWidth : computed.borderBottomWidth)
51
+ : 0
52
+ // computed が取れない環境 (jsdom 等) では従来の offset 寸法へフォールバックする。
53
+ const layoutSize = computedLayoutSize > 0 ? computedLayoutSize : isX ? element.offsetWidth : element.offsetHeight
54
+ if (layoutSize <= 0 || visualSize <= 0) {
55
+ return 1
56
+ }
57
+ return visualSize / layoutSize
58
+ }