@aiquants/virtualscroll 1.20.0 → 1.22.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +5 -3
  3. package/dist/ScrollBar.d.cts +123 -0
  4. package/dist/ScrollBar.d.ts +123 -0
  5. package/dist/ScrollBar.d.ts.map +1 -1
  6. package/dist/ScrollPane.d.cts +16 -0
  7. package/dist/ScrollPane.d.ts +16 -0
  8. package/dist/ScrollPane.d.ts.map +1 -1
  9. package/dist/TapScrollCircle.d.ts.map +1 -1
  10. package/dist/VirtualScroll.d.cts +9 -0
  11. package/dist/VirtualScroll.d.ts +9 -0
  12. package/dist/VirtualScroll.d.ts.map +1 -1
  13. package/dist/index.cjs +1 -1
  14. package/dist/index.d.cts +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1558 -1436
  18. package/dist/styles/virtualscroll.css +1 -1
  19. package/dist/styles/virtualscroll.standalone.css +1 -1
  20. package/dist/tapScrollCircleSampleVisual.d.ts.map +1 -1
  21. package/dist/useFenwickMapTree.d.cts +43 -0
  22. package/dist/useFenwickMapTree.d.ts +43 -0
  23. package/dist/useFenwickMapTree.d.ts.map +1 -1
  24. package/package.json +4 -1
  25. package/src/ScrollBar.tsx +218 -33
  26. package/src/ScrollPane.tsx +366 -24
  27. package/src/TapScrollCircle.tsx +10 -0
  28. package/src/VirtualScroll.tsx +11 -1
  29. package/src/index.ts +1 -1
  30. package/src/styles/virtualscroll.css +3 -3
  31. package/src/tapScrollCircleSampleVisual.tsx +22 -5
  32. package/src/useFenwickMapTree.ts +95 -13
  33. package/src/ScrollBar.spec.tsx +0 -620
  34. package/src/ScrollPane.spec.tsx +0 -496
  35. package/src/TapScrollCircle.spec.tsx +0 -275
  36. package/src/VirtualScroll.spec.ts +0 -641
  37. package/src/cli.server.spec.ts +0 -137
  38. package/src/logger.spec.ts +0 -128
  39. package/src/useFenwickMapTree.huge.spec.ts +0 -388
  40. package/src/useFenwickMapTree.spec.ts +0 -1518
  41. package/src/useLruCache.spec.ts +0 -382
  42. package/src/utils.spec.ts +0 -39
@@ -1,620 +0,0 @@
1
- /**
2
- * Regression tests for ScrollBar pointer/drag robustness fixes.
3
- *
4
- * ScrollBar のポインタ/ドラッグ堅牢化修正に関する回帰テスト。
5
- * - サムドラッグの pointer capture 取得とアンマウント時 document リスナ解除
6
- * - pointer capture 取得失敗時のフォールバックと pointerup 時のキャプチャ解放
7
- * - pointerId による別ポインターイベントの弁別
8
- * - 矢印リピートタイマーの blur/無効化時解除
9
- * - transform scale 下でのドラッグ量補正 (transform なしでの厳密 scale=1 を含む)
10
- * - 量子化する親での矢印ボタン/キーボードの残差前進
11
- * - scrollPosition 範囲外時のサム位置クランプ
12
- * - タップ自動スクロールの方向転換時の残差リセットと量子化親での境界到達判定
13
- * - トラックドラッグの終了経路 (hasPointerCapture ガード / lostpointercapture / stale ドラッグ破棄)
14
- * - 矢印長押し中の viewportSize 変更へのステップ量追従
15
- */
16
- import { act, fireEvent, render } from "@testing-library/react"
17
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
18
- import { ScrollBar } from "./ScrollBar.tsx"
19
-
20
- const CONTENT_SIZE = 1000
21
- const VIEWPORT_SIZE = 200
22
- const SCROLL_BAR_WIDTH = 12
23
- // 上記パラメータから導かれる派生値: trackLength=176, thumbSize=35.2, effectiveTrackLength=140.8, maxScroll=800
24
- const EFFECTIVE_TRACK_LENGTH = 140.8
25
- const MAX_SCROLL = CONTENT_SIZE - VIEWPORT_SIZE
26
-
27
- const getThumbWrapper = (container: HTMLElement) => container.querySelector(".aqvs-scrollbar-thumb-wrapper") as HTMLElement
28
- const getArrowButton = (container: HTMLElement, label: string) => container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement
29
-
30
- /**
31
- * Dispatches a native pointer-typed MouseEvent so that clientX/clientY are delivered
32
- * (fireEvent's synthetic pointer events do not carry coordinates in jsdom).
33
- *
34
- * clientX/clientY を伴うネイティブ pointer イベントを発火する (jsdom では fireEvent の
35
- * pointer イベントが座標を運ばないため)。jsdom の MouseEvent は pointerId を持たないため、
36
- * ポインター弁別を実際に判定できるよう pointerId (既定 1) を明示的に付与する。
37
- * buttons / pointerType は必要なテストのみ明示指定する (省略時は buttons=0, pointerType なし)。
38
- */
39
- const dispatchPointer = (target: EventTarget, type: string, init: { clientX?: number; clientY?: number; pointerId?: number; buttons?: number; pointerType?: string } = {}) => {
40
- act(() => {
41
- const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: init.clientX ?? 0, clientY: init.clientY ?? 0, buttons: init.buttons ?? 0 })
42
- // ドラッグ状態の pointerId 照合が undefined 同士の常時一致にならないよう必ず数値を持たせる。
43
- Object.defineProperty(event, "pointerId", { configurable: true, value: init.pointerId ?? 1 })
44
- if (init.pointerType !== undefined) {
45
- Object.defineProperty(event, "pointerType", { configurable: true, value: init.pointerType })
46
- }
47
- target.dispatchEvent(event)
48
- })
49
- }
50
-
51
- /**
52
- * Creates an onScroll mock that quantizes positions to a fixed quantum (row-snap parent).
53
- *
54
- * 位置を一定量子 (行スナップ) に丸めて返す onScroll モックを生成。
55
- */
56
- const createQuantizingOnScroll = (quantum: number) => {
57
- const state = { position: 0 }
58
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>((next, prev) => {
59
- // 親が候補位置を行境界へ floor スナップして返す挙動を再現する。
60
- const candidate = typeof next === "function" ? next(prev ?? state.position) : next
61
- state.position = Math.floor(candidate / quantum) * quantum
62
- return state.position
63
- })
64
- return { state, onScroll }
65
- }
66
-
67
- describe("ScrollBar サム位置クランプ (レンダー時)", () => {
68
- it("scrollPosition が最大値を超えてもサムはトラック内に収まる", () => {
69
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={MAX_SCROLL * 10} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={() => undefined} />)
70
- const top = Number.parseFloat(getThumbWrapper(container).style.top)
71
- expect(top).toBeCloseTo(EFFECTIVE_TRACK_LENGTH, 1)
72
- expect(top).toBeLessThanOrEqual(EFFECTIVE_TRACK_LENGTH + 0.01)
73
- })
74
-
75
- it("負の scrollPosition でもサムはトラック先頭 (0) に留まる", () => {
76
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={-500} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={() => undefined} />)
77
- const top = Number.parseFloat(getThumbWrapper(container).style.top)
78
- expect(top).toBe(0)
79
- })
80
- })
81
-
82
- describe("ScrollBar サムドラッグ pointer capture / リーク", () => {
83
- it("サム押下でポインターキャプチャを取得する", () => {
84
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={() => undefined} />)
85
- const thumb = getThumbWrapper(container)
86
- const setPointerCapture = vi.fn()
87
- // jsdom は setPointerCapture を実装しないため、要素インスタンスに mock を差し込む。
88
- Object.defineProperty(thumb, "setPointerCapture", { configurable: true, value: setPointerCapture })
89
-
90
- dispatchPointer(thumb, "pointerdown", { clientY: 0 })
91
-
92
- expect(setPointerCapture).toHaveBeenCalledTimes(1)
93
- })
94
-
95
- it("ドラッグ中にアンマウントしても document リスナが残らず onScroll を呼び続けない", () => {
96
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
97
- const { container, unmount } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
98
- const thumb = getThumbWrapper(container)
99
-
100
- dispatchPointer(thumb, "pointerdown", { clientY: 0 })
101
- dispatchPointer(document, "pointermove", { clientY: 40 })
102
- const callsWhileMounted = onScroll.mock.calls.length
103
- expect(callsWhileMounted).toBeGreaterThan(0)
104
-
105
- unmount()
106
-
107
- dispatchPointer(document, "pointermove", { clientY: 80 })
108
- expect(onScroll.mock.calls.length).toBe(callsWhileMounted)
109
- })
110
- })
111
-
112
- describe("ScrollBar transform scale 下のドラッグ量補正", () => {
113
- /**
114
- * scale=0.5 で視覚移動 D を与えた結果が、scale=1 で視覚移動 2D を与えた結果と一致することを確認。
115
- * (視覚pxをレイアウトpxへ復元できていれば両者は同じスクロール位置になる。)
116
- */
117
- it("scale=0.5 での移動 D が scale=1 での移動 2D と同じスクロール位置になる", () => {
118
- const capturedFirstArg = (fn: ReturnType<typeof vi.fn>): number => {
119
- const lastCall = fn.mock.calls.at(-1)
120
- return lastCall?.[0] as number
121
- }
122
-
123
- // --- scale = 0.5 ---
124
- const onScrollA = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
125
- const { container: containerA, unmount: unmountA } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScrollA} />)
126
- const thumbA = getThumbWrapper(containerA)
127
- Object.defineProperty(thumbA, "getBoundingClientRect", { configurable: true, value: () => ({ height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }) })
128
- // レイアウト高さ 100px (視覚 50px -> scale=0.5)。getComputedStyle 経路 (inline style) と offset 経路の両方を揃える。
129
- thumbA.style.height = "100px"
130
- Object.defineProperty(thumbA, "offsetHeight", { configurable: true, value: 100 })
131
- dispatchPointer(thumbA, "pointerdown", { clientY: 0 })
132
- dispatchPointer(document, "pointermove", { clientY: 10 })
133
- const resultScaled = capturedFirstArg(onScrollA)
134
- unmountA()
135
-
136
- // --- scale = 1 (jsdom 既定: rect/offset が 0 -> scale=1) ---
137
- const onScrollB = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
138
- const { container: containerB } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScrollB} />)
139
- const thumbB = getThumbWrapper(containerB)
140
- dispatchPointer(thumbB, "pointerdown", { clientY: 0 })
141
- dispatchPointer(document, "pointermove", { clientY: 20 })
142
- const resultUnscaled = capturedFirstArg(onScrollB)
143
-
144
- expect(resultScaled).toBeGreaterThan(0)
145
- expect(resultScaled).toBeCloseTo(resultUnscaled, 3)
146
- // scale を無視していれば視覚移動 10 は約 56.8 になるはず。補正により約 113.6 になることを確認。
147
- expect(resultScaled).toBeCloseTo((20 / EFFECTIVE_TRACK_LENGTH) * MAX_SCROLL, 1)
148
- })
149
-
150
- /**
151
- * transform なし環境の再現: 小数 thumbSize (35.2px) に対し offsetHeight は整数丸め (35) を返す。
152
- * 整数丸めの offsetHeight を分母にすると scale=35.2/35 と誤算出されドラッグ量が系統的に縮む。
153
- * getComputedStyle の小数精度レイアウト値を分母にすれば scale は厳密 1 になる。
154
- */
155
- it("transform なし (rect=inline style の小数 thumbSize) では scale が厳密 1 でドラッグが 1:1 になる", () => {
156
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
157
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
158
- const thumb = getThumbWrapper(container)
159
- // 実ブラウザ相当: rect は小数 (transform なしなので inline style の 35.2 と一致)、offsetHeight は整数丸め。
160
- Object.defineProperty(thumb, "getBoundingClientRect", { configurable: true, value: () => ({ height: 35.2, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }) })
161
- Object.defineProperty(thumb, "offsetHeight", { configurable: true, value: 35 })
162
-
163
- dispatchPointer(thumb, "pointerdown", { clientY: 0 })
164
- dispatchPointer(document, "pointermove", { clientY: 10 })
165
-
166
- const lastCall = onScroll.mock.calls.at(-1)
167
- const result = lastCall?.[0] as number
168
- // 視覚移動 10px がそのままレイアウト 10px として扱われること (scale=1 厳密)。
169
- // 整数丸め分母 (35) だと 10/(35.2/35)=9.943 -> 約 56.49 となり precision 6 では一致しない。
170
- expect(result).toBeCloseTo((10 / EFFECTIVE_TRACK_LENGTH) * MAX_SCROLL, 6)
171
- })
172
- })
173
-
174
- describe("ScrollBar 矢印リピートの blur / 無効化時解除", () => {
175
- beforeEach(() => {
176
- vi.useFakeTimers()
177
- })
178
-
179
- afterEach(() => {
180
- vi.useRealTimers()
181
- })
182
-
183
- it("長押し中に window blur するとリピートが停止する", () => {
184
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
185
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
186
- const downButton = getArrowButton(container, "Scroll down")
187
-
188
- act(() => {
189
- fireEvent.pointerDown(downButton)
190
- vi.advanceTimersByTime(250 + 180)
191
- })
192
- const callsBeforeBlur = onScroll.mock.calls.length
193
- expect(callsBeforeBlur).toBeGreaterThan(1)
194
-
195
- act(() => {
196
- window.dispatchEvent(new Event("blur"))
197
- })
198
- act(() => {
199
- vi.advanceTimersByTime(500)
200
- })
201
-
202
- expect(onScroll.mock.calls.length).toBe(callsBeforeBlur)
203
- })
204
-
205
- it("長押し中に矢印ボタンが無効化 (canUseArrowButtons=false) されるとリピートが停止する", () => {
206
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
207
- const { container, rerender } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
208
- const downButton = getArrowButton(container, "Scroll down")
209
-
210
- act(() => {
211
- fireEvent.pointerDown(downButton)
212
- vi.advanceTimersByTime(250 + 180)
213
- })
214
- const callsBeforeDisable = onScroll.mock.calls.length
215
- expect(callsBeforeDisable).toBeGreaterThan(1)
216
-
217
- // コンテンツが縮んでスクロール不能になる -> canUseArrowButtons=false -> リピート停止。
218
- act(() => {
219
- rerender(<ScrollBar contentSize={VIEWPORT_SIZE / 2} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
220
- })
221
- act(() => {
222
- vi.advanceTimersByTime(500)
223
- })
224
-
225
- expect(onScroll.mock.calls.length).toBe(callsBeforeDisable)
226
- })
227
- })
228
-
229
- describe("ScrollBar pointer capture フォールバック / 解放 / pointerId 弁別", () => {
230
- it("サムの setPointerCapture が throw してもドラッグは継続する (フォールバック)", () => {
231
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
232
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
233
- const thumb = getThumbWrapper(container)
234
- // 非アクティブな pointerId に対する実ブラウザの挙動 (NotFoundError throw) を再現する。
235
- Object.defineProperty(thumb, "setPointerCapture", {
236
- configurable: true,
237
- value: () => {
238
- throw new DOMException("invalid pointerId", "NotFoundError")
239
- },
240
- })
241
-
242
- dispatchPointer(thumb, "pointerdown", { clientY: 0 })
243
- dispatchPointer(document, "pointermove", { clientY: 40 })
244
-
245
- // キャプチャ取得に失敗しても document リスナ経由でドラッグ追従が機能すること。
246
- expect(onScroll.mock.calls.length).toBeGreaterThan(0)
247
- })
248
-
249
- it("トラックの setPointerCapture が throw してもドラッグ状態が設定されトラックドラッグは継続する (フォールバック)", () => {
250
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
251
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
252
- const track = container.querySelector(".aqvs-scrollbar-track") as HTMLElement
253
- Object.defineProperty(track, "setPointerCapture", {
254
- configurable: true,
255
- value: () => {
256
- throw new DOMException("invalid pointerId", "NotFoundError")
257
- },
258
- })
259
-
260
- dispatchPointer(track, "pointerdown", { clientY: 100 })
261
- const callsAfterDown = onScroll.mock.calls.length
262
- // 初回ジャンプ (押下位置へのスクロール) が発生していること。
263
- expect(callsAfterDown).toBeGreaterThan(0)
264
-
265
- // throw 後も trackDragStateRef が設定されており、トラック上の pointermove で追従すること。
266
- dispatchPointer(track, "pointermove", { clientY: 120 })
267
- expect(onScroll.mock.calls.length).toBeGreaterThan(callsAfterDown)
268
- })
269
-
270
- it("サムの pointerup でポインターキャプチャが解放される", () => {
271
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
272
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
273
- const thumb = getThumbWrapper(container)
274
- const releasePointerCapture = vi.fn()
275
- Object.defineProperty(thumb, "setPointerCapture", { configurable: true, value: vi.fn() })
276
- Object.defineProperty(thumb, "hasPointerCapture", { configurable: true, value: () => true })
277
- Object.defineProperty(thumb, "releasePointerCapture", { configurable: true, value: releasePointerCapture })
278
-
279
- dispatchPointer(thumb, "pointerdown", { clientY: 0, pointerId: 7 })
280
- dispatchPointer(document, "pointerup", { clientY: 0, pointerId: 7 })
281
-
282
- expect(releasePointerCapture).toHaveBeenCalledTimes(1)
283
- expect(releasePointerCapture).toHaveBeenCalledWith(7)
284
- })
285
-
286
- it("別 pointerId の pointermove/pointerup はドラッグ状態に影響しない (弁別)", () => {
287
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
288
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
289
- const thumb = getThumbWrapper(container)
290
-
291
- dispatchPointer(thumb, "pointerdown", { clientY: 0, pointerId: 7 })
292
-
293
- // 別ポインター (pointerId=8) の移動は無視されること。
294
- dispatchPointer(document, "pointermove", { clientY: 40, pointerId: 8 })
295
- expect(onScroll).not.toHaveBeenCalled()
296
-
297
- // 別ポインターの pointerup でドラッグが終了しないこと (その後も id=7 の移動で追従する)。
298
- dispatchPointer(document, "pointerup", { clientY: 40, pointerId: 8 })
299
- dispatchPointer(document, "pointermove", { clientY: 40, pointerId: 7 })
300
- expect(onScroll.mock.calls.length).toBeGreaterThan(0)
301
- })
302
- })
303
-
304
- describe("ScrollBar 量子化する親での残差前進 (矢印ボタン/キーボード)", () => {
305
- beforeEach(() => {
306
- vi.useFakeTimers()
307
- })
308
-
309
- afterEach(() => {
310
- vi.useRealTimers()
311
- })
312
-
313
- // 量子 (50px) > 矢印ステップ (viewport 200 -> step 20) の構成。残差蓄積が無いと永久に前進しない。
314
- const QUANTUM = 50
315
-
316
- it("行スナップする親でも矢印ボタン長押しで残差が蓄積して前進する", () => {
317
- const { state, onScroll } = createQuantizingOnScroll(QUANTUM)
318
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
319
- const downButton = getArrowButton(container, "Scroll down")
320
-
321
- act(() => {
322
- fireEvent.pointerDown(downButton)
323
- })
324
- // 押下直後の 1 ステップ (20px) は量子未満で位置は動かない。
325
- expect(state.position).toBe(0)
326
-
327
- act(() => {
328
- // 長押し 250ms 後にリピート開始、60ms 間隔。3 ステップ目 (累計 60px) で量子 50px を跨いで前進する。
329
- vi.advanceTimersByTime(250 + 60 * 2)
330
- })
331
- expect(state.position).toBe(QUANTUM)
332
-
333
- act(() => {
334
- fireEvent.pointerUp(downButton)
335
- })
336
- })
337
-
338
- it("行スナップする親でもキーボード (Enter/Space) の連続押下で残差が蓄積して前進する", () => {
339
- const { state, onScroll } = createQuantizingOnScroll(QUANTUM)
340
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
341
- const downButton = getArrowButton(container, "Scroll down")
342
-
343
- act(() => {
344
- fireEvent.keyDown(downButton, { key: "Enter" })
345
- fireEvent.keyDown(downButton, { key: " " })
346
- })
347
- // 2 押下 (累計 40px) では量子未満で動かない。
348
- expect(state.position).toBe(0)
349
-
350
- act(() => {
351
- fireEvent.keyDown(downButton, { key: "Enter" })
352
- })
353
- // 3 押下目 (累計 60px) で量子 50px を跨いで前進する。
354
- expect(state.position).toBe(QUANTUM)
355
- })
356
-
357
- it("方向転換時は逆向きの残差を持ち越さない", () => {
358
- const { state, onScroll } = createQuantizingOnScroll(QUANTUM)
359
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
360
- const downButton = getArrowButton(container, "Scroll down")
361
- const upButton = getArrowButton(container, "Scroll up")
362
-
363
- act(() => {
364
- fireEvent.keyDown(downButton, { key: "Enter" })
365
- fireEvent.keyDown(downButton, { key: "Enter" })
366
- })
367
- // 下向き残差 40px が溜まった状態から上向きへ転換すると残差は破棄され、位置 0 のまま。
368
- act(() => {
369
- fireEvent.keyDown(upButton, { key: "Enter" })
370
- })
371
- expect(state.position).toBe(0)
372
-
373
- // 転換後は下向きも残差 0 から数え直しになる (3 押下で前進)。
374
- act(() => {
375
- fireEvent.keyDown(downButton, { key: "Enter" })
376
- fireEvent.keyDown(downButton, { key: "Enter" })
377
- fireEvent.keyDown(downButton, { key: "Enter" })
378
- })
379
- expect(state.position).toBe(QUANTUM)
380
- })
381
- })
382
-
383
- describe("ScrollBar タップ自動スクロールの残差 (方向転換 / 量子化境界)", () => {
384
- // rAF をタイムスタンプ制御可能な手動ハーネスへ差し替える。
385
- let rafCallbacks: Map<number, FrameRequestCallback>
386
-
387
- beforeEach(() => {
388
- rafCallbacks = new Map()
389
- let nextRafId = 1
390
- vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
391
- const id = nextRafId
392
- nextRafId += 1
393
- rafCallbacks.set(id, callback)
394
- return id
395
- })
396
- vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id: number) => {
397
- rafCallbacks.delete(id)
398
- })
399
- })
400
-
401
- afterEach(() => {
402
- vi.restoreAllMocks()
403
- })
404
-
405
- /**
406
- * Flushes all pending rAF callbacks with the given timestamp.
407
- *
408
- * 保留中の rAF コールバックを指定タイムスタンプで一括実行する。
409
- */
410
- const flushFrame = (timestamp: number) => {
411
- const callbacks = [...rafCallbacks.values()]
412
- rafCallbacks.clear()
413
- act(() => {
414
- for (const callback of callbacks) {
415
- callback(timestamp)
416
- }
417
- })
418
- }
419
-
420
- const getTapCircle = (container: HTMLElement) => container.querySelector('[data-testid="virtual-scroll-tap-circle"]') as HTMLElement
421
-
422
- // minSpeedMultiplier = maxSpeedMultiplier = 0.5 -> speed は距離に依らず viewport 200 * 0.5 = 100px/s 固定。
423
- // フレーム間隔 100ms (上限 0.1s) で 1 フレームあたり厳密に 10px の要求になる。
424
- const FIXED_SPEED_OPTIONS = { maxSpeedMultiplier: 0.5, minSpeedMultiplier: 0.5 }
425
- const QUANTUM = 60
426
-
427
- it("タップ自動スクロールの方向転換時に逆向きの残差を持ち越さない", () => {
428
- const { state, onScroll } = createQuantizingOnScroll(QUANTUM)
429
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} tapScrollCircleOptions={FIXED_SPEED_OPTIONS} />)
430
- const circle = getTapCircle(container)
431
-
432
- // 円の中心 (jsdom では rect が全て 0 -> 中心 (0,0)) から下方向 100px へドラッグ -> direction=+1。
433
- dispatchPointer(circle, "pointerdown", { clientY: 0 })
434
- dispatchPointer(circle, "pointermove", { clientY: 100 })
435
-
436
- flushFrame(0)
437
- // 11 フレーム (各 +10px 要求、累計 110px) -> 量子 60 で位置 60、下向き残差 50 が残る。
438
- for (let frame = 1; frame <= 11; frame += 1) {
439
- flushFrame(frame * 100)
440
- }
441
- expect(state.position).toBe(QUANTUM)
442
-
443
- // ヒステリシス幅 (14px) を超える移動で 1 イベントで上方向 (-1) へ反転する。
444
- dispatchPointer(circle, "pointermove", { clientY: -100 })
445
- flushFrame(1200)
446
-
447
- // 修正前: 下向き残差 50 の燃焼待ちで位置 60 に固着。修正後: 残差破棄により即座に上へ前進する。
448
- expect(state.position).toBe(0)
449
- })
450
-
451
- it("量子化で maxScrollPosition ちょうどに到達できない親でも末端で境界到達と判定されループが停止する", () => {
452
- // 到達可能最大 = floor(800 / 60) * 60 = 780 (maxScroll 800 に厳密到達できない構成)。
453
- const { state, onScroll } = createQuantizingOnScroll(QUANTUM)
454
- state.position = 780
455
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={780} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} tapScrollCircleOptions={FIXED_SPEED_OPTIONS} />)
456
- const circle = getTapCircle(container)
457
-
458
- dispatchPointer(circle, "pointerdown", { clientY: 0 })
459
- dispatchPointer(circle, "pointermove", { clientY: 100 })
460
-
461
- // 数フレームで要求候補 (780 + 残差) が maxScroll 800 に達し、境界到達としてループが停止する。
462
- for (let frame = 0; frame <= 6; frame += 1) {
463
- flushFrame(frame * 100)
464
- }
465
-
466
- expect(state.position).toBe(780)
467
- // 修正前: reachedBoundary が永久に偽のまま rAF が再スケジュールされ続け、残差も無制限に成長する。
468
- expect(rafCallbacks.size).toBe(0)
469
- })
470
- })
471
-
472
- describe("ScrollBar トラックドラッグの終了経路", () => {
473
- const getTrack = (container: HTMLElement) => container.querySelector(".aqvs-scrollbar-track") as HTMLElement
474
-
475
- it("hasPointerCapture 未実装環境 (jsdom) でもトラックの pointerup でドラッグが終了する", () => {
476
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
477
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
478
- const track = getTrack(container)
479
- // jsdom の Element は hasPointerCapture を実装しない。存在ガードが無いと TypeError で状態が残留する。
480
- expect(typeof track.hasPointerCapture).toBe("undefined")
481
-
482
- dispatchPointer(track, "pointerdown", { clientY: 100 })
483
- const callsAfterDown = onScroll.mock.calls.length
484
- expect(callsAfterDown).toBeGreaterThan(0)
485
-
486
- dispatchPointer(track, "pointerup", { clientY: 100 })
487
-
488
- // 終了後の pointermove では追従しない (ドラッグ状態が破棄されている)。
489
- dispatchPointer(track, "pointermove", { clientY: 130 })
490
- expect(onScroll.mock.calls.length).toBe(callsAfterDown)
491
- })
492
-
493
- it("hasPointerCapture 未実装環境 (jsdom) でもトラックの pointercancel でドラッグが終了する", () => {
494
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
495
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
496
- const track = getTrack(container)
497
-
498
- dispatchPointer(track, "pointerdown", { clientY: 100 })
499
- const callsAfterDown = onScroll.mock.calls.length
500
-
501
- dispatchPointer(track, "pointercancel", { clientY: 100 })
502
-
503
- dispatchPointer(track, "pointermove", { clientY: 130 })
504
- expect(onScroll.mock.calls.length).toBe(callsAfterDown)
505
- })
506
-
507
- it("トラックドラッグ中に lostpointercapture が発生するとドラッグが終了する", () => {
508
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
509
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
510
- const track = getTrack(container)
511
-
512
- dispatchPointer(track, "pointerdown", { clientY: 100 })
513
- const callsAfterDown = onScroll.mock.calls.length
514
-
515
- // 要素削除や OS 割り込みなどブラウザ都合でキャプチャを失った想定。
516
- dispatchPointer(track, "lostpointercapture", {})
517
-
518
- dispatchPointer(track, "pointermove", { clientY: 130 })
519
- expect(onScroll.mock.calls.length).toBe(callsAfterDown)
520
- })
521
-
522
- it("pointerup を取りこぼしても、マウスのボタン非押下 (buttons=0) の pointermove で stale ドラッグを破棄する", () => {
523
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>(() => undefined)
524
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
525
- const track = getTrack(container)
526
-
527
- dispatchPointer(track, "pointerdown", { clientY: 100 })
528
- const callsAfterDown = onScroll.mock.calls.length
529
-
530
- // pointerup はトラック外で発生して取りこぼした想定 (dispatch しない)。
531
- // その後のボタン非押下ホバー移動でスクロールしてはならない。
532
- dispatchPointer(track, "pointermove", { clientY: 130, pointerType: "mouse", buttons: 0 })
533
- expect(onScroll.mock.calls.length).toBe(callsAfterDown)
534
-
535
- // stale 状態は破棄済みなので、以後ボタン押下相当の移動が来ても追従しない。
536
- dispatchPointer(track, "pointermove", { clientY: 150, pointerType: "mouse", buttons: 1 })
537
- expect(onScroll.mock.calls.length).toBe(callsAfterDown)
538
- })
539
- })
540
-
541
- describe("ScrollBar 矢印長押し中の viewportSize 変更", () => {
542
- beforeEach(() => {
543
- vi.useFakeTimers()
544
- })
545
-
546
- afterEach(() => {
547
- vi.useRealTimers()
548
- })
549
-
550
- it("長押しリピート中に viewportSize が変わるとステップ量が最新値に追従する", () => {
551
- let position = 0
552
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>((next, prev) => {
553
- position = typeof next === "function" ? next(prev ?? position) : next
554
- return position
555
- })
556
- const { container, rerender } = render(<ScrollBar contentSize={10000} viewportSize={200} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
557
- const downButton = getArrowButton(container, "Scroll down")
558
-
559
- act(() => {
560
- fireEvent.pointerDown(downButton)
561
- })
562
- // viewport 200 -> step 20 (押下直後の 1 ステップ)。
563
- expect(position).toBe(20)
564
-
565
- act(() => {
566
- // 250ms でリピート開始、60ms 間隔の 1 tick 目 -> +20。
567
- vi.advanceTimersByTime(250 + 60)
568
- })
569
- expect(position).toBe(40)
570
-
571
- // 長押し中にビューポートが拡大 (step は 1000/20 = 50 になるはず)。
572
- act(() => {
573
- rerender(<ScrollBar contentSize={10000} viewportSize={1000} scrollPosition={position} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
574
- })
575
- const positionBeforeTick = position
576
- act(() => {
577
- vi.advanceTimersByTime(60)
578
- })
579
- // 修正前: setInterval クロージャが押下時点の scrollByStep (step 20) を掴んだまま -> +20。
580
- // 修正後: latestMetricsRef 経由で最新の viewportSize を参照 -> +50。
581
- expect(position).toBe(positionBeforeTick + 50)
582
-
583
- act(() => {
584
- fireEvent.pointerUp(downButton)
585
- })
586
- })
587
- })
588
-
589
- describe("ScrollBar キーボード到達可能性", () => {
590
- it("矢印ボタンは tabIndex=0 でタブ順序に含まれ、Enter/Space でスクロールする", () => {
591
- let position = 0
592
- const onScroll = vi.fn<(next: number | ((p: number) => number), prev?: number) => number | undefined>((next, prev) => {
593
- position = typeof next === "function" ? next(prev ?? position) : next
594
- return position
595
- })
596
- const { container } = render(<ScrollBar contentSize={CONTENT_SIZE} viewportSize={VIEWPORT_SIZE} scrollPosition={0} scrollBarWidth={SCROLL_BAR_WIDTH} onScroll={onScroll} />)
597
- const downButton = getArrowButton(container, "Scroll down")
598
- const upButton = getArrowButton(container, "Scroll up")
599
-
600
- // キーボードで到達可能 (タブ順序に含まれる) こと。
601
- expect(downButton.tabIndex).toBe(0)
602
- expect(upButton.tabIndex).toBe(0)
603
-
604
- act(() => {
605
- fireEvent.keyDown(downButton, { key: "Enter" })
606
- })
607
- // viewport 200 -> step 20 だけ前進する。
608
- expect(position).toBe(20)
609
-
610
- act(() => {
611
- fireEvent.keyDown(downButton, { key: " " })
612
- })
613
- expect(position).toBe(40)
614
-
615
- act(() => {
616
- fireEvent.keyDown(upButton, { key: "Enter" })
617
- })
618
- expect(position).toBe(20)
619
- })
620
- })