@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.
- package/CHANGELOG.md +54 -0
- package/README.md +78 -10
- package/dist/VirtualScroll.d.cts +103 -9
- package/dist/VirtualScroll.d.ts +103 -9
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1571 -1512
- package/dist/residualQuantizer.d.cts +95 -0
- package/dist/residualQuantizer.d.ts +96 -0
- package/dist/residualQuantizer.d.ts.map +1 -0
- package/dist/styles/virtualscroll.css +1 -1
- package/dist/styles/virtualscroll.standalone.css +1 -1
- package/package.json +1 -1
- package/src/VirtualScroll.tsx +237 -44
- package/src/index.ts +3 -0
- package/src/residualQuantizer.ts +133 -0
- package/src/styles/virtualscroll.css +15 -0
package/src/VirtualScroll.tsx
CHANGED
|
@@ -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
|
-
* - `["
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
-
* ❗
|
|
262
|
-
*
|
|
263
|
-
* `
|
|
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
|
|
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
|
-
|
|
1920
|
-
|
|
2004
|
+
// ❗ Shift 併用は無条件で消費しない (3.0.0 で "shift-arrow" 入力を撤去)。ブラウザ標準では
|
|
2005
|
+
// 選択範囲の伸縮であり、選択の有無での条件分岐はしない — 選択検出は Shadow DOM で
|
|
2006
|
+
// 可搬に成立せず、Ctrl+A 全選択では全行が交差して解除まで全面ロックアウトするため
|
|
2007
|
+
if (event.shiftKey) {
|
|
1921
2008
|
return
|
|
1922
2009
|
}
|
|
1923
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|