@aiquants/virtualscroll 1.19.3 → 1.22.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 +73 -0
- package/README.md +9 -6
- package/dist/ScrollBar.d.cts +123 -0
- package/dist/ScrollBar.d.ts +123 -0
- package/dist/ScrollBar.d.ts.map +1 -1
- package/dist/ScrollPane.d.cts +19 -1
- package/dist/ScrollPane.d.ts +19 -1
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +9 -0
- package/dist/VirtualScroll.d.ts +9 -0
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/_headFenwick.d.cts +450 -0
- package/dist/_headFenwick.d.ts +451 -0
- package/dist/_headFenwick.d.ts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1470 -1360
- package/dist/styles/virtualscroll.css +1 -1
- package/dist/styles/virtualscroll.standalone.css +1 -1
- package/dist/tapScrollCircleSampleVisual.d.ts.map +1 -1
- package/dist/useFenwickMapTree.d.cts +43 -0
- package/dist/useFenwickMapTree.d.ts +43 -0
- package/dist/useFenwickMapTree.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/ScrollBar.tsx +211 -33
- package/src/ScrollPane.tsx +252 -35
- package/src/VirtualScroll.tsx +11 -1
- package/src/index.ts +1 -1
- package/src/styles/virtualscroll.css +3 -3
- package/src/tapScrollCircleSampleVisual.tsx +22 -5
- package/src/useFenwickMapTree.ts +95 -13
- package/src/ScrollBar.spec.tsx +0 -620
- package/src/ScrollPane.spec.tsx +0 -482
- package/src/TapScrollCircle.spec.tsx +0 -275
- package/src/VirtualScroll.spec.ts +0 -641
- package/src/cli.server.spec.ts +0 -137
- package/src/logger.spec.ts +0 -128
- package/src/useFenwickMapTree.huge.spec.ts +0 -388
- package/src/useFenwickMapTree.spec.ts +0 -1518
- package/src/useLruCache.spec.ts +0 -382
- package/src/utils.spec.ts +0 -39
package/src/ScrollPane.spec.tsx
DELETED
|
@@ -1,482 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Regression tests for ScrollPane fixes (G4 group).
|
|
3
|
-
* Covers negative initialScrollPosition clamping, the conditional
|
|
4
|
-
* wheel preventDefault behavior (ctrl+wheel zoom / horizontal wheel),
|
|
5
|
-
* pointer-drag state robustness (lost pointerup / lostpointercapture /
|
|
6
|
-
* second-pointer takeover / synchronous lostpointercapture on release),
|
|
7
|
-
* inertia robustness (stale velocity samples / reentrant stopInertia),
|
|
8
|
-
* scrollTo non-finite guarding, conditional touchAction, and lazy
|
|
9
|
-
* debug-log argument evaluation.
|
|
10
|
-
*
|
|
11
|
-
* ScrollPane の修正に対する回帰テスト(G4 グループ)。
|
|
12
|
-
* 負の initialScrollPosition のクランプ、ホイールの条件付き preventDefault
|
|
13
|
-
* (ctrl+wheel ズーム / 水平ホイール)、ポインタドラッグ状態の堅牢化
|
|
14
|
-
* (pointerup 喪失 / lostpointercapture / 2 本目ポインタの乗っ取り /
|
|
15
|
-
* 解放時の同期 lostpointercapture)、慣性の堅牢化 (静止後リリース /
|
|
16
|
-
* 再入 stopInertia)、scrollTo の非有限値ガード、touchAction の条件付き適用、
|
|
17
|
-
* デバッグログ引数の遅延評価を検証します。
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { cleanup, fireEvent, render } from "@testing-library/react"
|
|
21
|
-
import { createRef } from "react"
|
|
22
|
-
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
|
23
|
-
import { Logger, LogLevel } from "./logger.ts"
|
|
24
|
-
import { ScrollPane, type ScrollPaneHandle } from "./ScrollPane"
|
|
25
|
-
|
|
26
|
-
// jsdom は PointerEvent とポインタキャプチャ API を持たないため補完する。
|
|
27
|
-
// jsdom lacks PointerEvent and pointer-capture APIs; polyfill them for these tests.
|
|
28
|
-
beforeAll(() => {
|
|
29
|
-
if (typeof window.PointerEvent === "undefined") {
|
|
30
|
-
class PointerEventPolyfill extends MouseEvent {
|
|
31
|
-
pointerId: number
|
|
32
|
-
pointerType: string
|
|
33
|
-
constructor(type: string, params: PointerEventInit = {}) {
|
|
34
|
-
super(type, params)
|
|
35
|
-
this.pointerId = params.pointerId ?? 0
|
|
36
|
-
this.pointerType = params.pointerType ?? "mouse"
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
// @ts-expect-error jsdom への PointerEvent 補完
|
|
40
|
-
window.PointerEvent = PointerEventPolyfill
|
|
41
|
-
}
|
|
42
|
-
HTMLElement.prototype.setPointerCapture ??= function setPointerCapture() {}
|
|
43
|
-
HTMLElement.prototype.releasePointerCapture ??= function releasePointerCapture() {}
|
|
44
|
-
HTMLElement.prototype.hasPointerCapture ??= function hasPointerCapture() {
|
|
45
|
-
return false
|
|
46
|
-
}
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
afterEach(() => {
|
|
50
|
-
cleanup()
|
|
51
|
-
vi.restoreAllMocks()
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
const dispatchWheel = (target: Element, init: WheelEventInit) => {
|
|
55
|
-
const event = new WheelEvent("wheel", { cancelable: true, bubbles: true, ...init })
|
|
56
|
-
target.dispatchEvent(event)
|
|
57
|
-
return event
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
describe("ScrollPane - negative initialScrollPosition clamp", () => {
|
|
61
|
-
it("clamps a negative initialScrollPosition to 0 on first render", () => {
|
|
62
|
-
let capturedPosition = Number.NaN
|
|
63
|
-
render(
|
|
64
|
-
<ScrollPane contentSize={1000} viewportSize={200} initialScrollPosition={-50}>
|
|
65
|
-
{(scrollPosition) => {
|
|
66
|
-
capturedPosition = scrollPosition
|
|
67
|
-
return <div>content</div>
|
|
68
|
-
}}
|
|
69
|
-
</ScrollPane>,
|
|
70
|
-
)
|
|
71
|
-
expect(capturedPosition).toBe(0)
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it("keeps a valid initialScrollPosition untouched", () => {
|
|
75
|
-
let capturedPosition = Number.NaN
|
|
76
|
-
render(
|
|
77
|
-
<ScrollPane contentSize={1000} viewportSize={200} initialScrollPosition={300}>
|
|
78
|
-
{(scrollPosition) => {
|
|
79
|
-
capturedPosition = scrollPosition
|
|
80
|
-
return <div>content</div>
|
|
81
|
-
}}
|
|
82
|
-
</ScrollPane>,
|
|
83
|
-
)
|
|
84
|
-
expect(capturedPosition).toBe(300)
|
|
85
|
-
})
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
describe("ScrollPane - conditional wheel preventDefault", () => {
|
|
89
|
-
const setup = (onScroll = vi.fn()) => {
|
|
90
|
-
const { container } = render(
|
|
91
|
-
<ScrollPane contentSize={1000} viewportSize={200} onScroll={onScroll}>
|
|
92
|
-
{() => <div>content</div>}
|
|
93
|
-
</ScrollPane>,
|
|
94
|
-
)
|
|
95
|
-
const pane = container.querySelector(".aqvs-scroll-pane")
|
|
96
|
-
if (!(pane instanceof HTMLElement)) {
|
|
97
|
-
throw new Error("scroll pane element not found")
|
|
98
|
-
}
|
|
99
|
-
return { pane, onScroll }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
it("prevents default and scrolls on a normal vertical wheel", () => {
|
|
103
|
-
const { pane, onScroll } = setup()
|
|
104
|
-
const event = dispatchWheel(pane, { deltaY: 100 })
|
|
105
|
-
expect(event.defaultPrevented).toBe(true)
|
|
106
|
-
expect(onScroll).toHaveBeenCalledWith(100, 0)
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it("does not prevent default nor scroll on ctrl+wheel (pinch zoom)", () => {
|
|
110
|
-
const { pane, onScroll } = setup()
|
|
111
|
-
const event = dispatchWheel(pane, { deltaY: 100, ctrlKey: true })
|
|
112
|
-
expect(event.defaultPrevented).toBe(false)
|
|
113
|
-
expect(onScroll).not.toHaveBeenCalled()
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it("does not prevent default nor scroll on a horizontal-only wheel", () => {
|
|
117
|
-
const { pane, onScroll } = setup()
|
|
118
|
-
const event = dispatchWheel(pane, { deltaX: 100, deltaY: 0 })
|
|
119
|
-
expect(event.defaultPrevented).toBe(false)
|
|
120
|
-
expect(onScroll).not.toHaveBeenCalled()
|
|
121
|
-
})
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Builds a PointerEvent-like init for jsdom fireEvent.pointer*.
|
|
126
|
-
*
|
|
127
|
-
* jsdom の fireEvent.pointer* 用に PointerEvent 相当の初期化を生成。
|
|
128
|
-
*/
|
|
129
|
-
const pointerInit = (pointerId: number, clientY: number, buttons: number) => ({
|
|
130
|
-
pointerId,
|
|
131
|
-
clientX: 0,
|
|
132
|
-
clientY,
|
|
133
|
-
buttons,
|
|
134
|
-
button: 0,
|
|
135
|
-
bubbles: true,
|
|
136
|
-
cancelable: true,
|
|
137
|
-
pointerType: "mouse",
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
const setupDragPane = (props: { contentSize?: number; viewportSize?: number } = {}) => {
|
|
141
|
-
const onScroll = vi.fn()
|
|
142
|
-
const { container } = render(
|
|
143
|
-
<ScrollPane contentSize={props.contentSize ?? 1000} viewportSize={props.viewportSize ?? 200} onScroll={onScroll}>
|
|
144
|
-
{() => <div>content</div>}
|
|
145
|
-
</ScrollPane>,
|
|
146
|
-
)
|
|
147
|
-
const content = container.querySelector(".aqvs-scroll-pane-content")
|
|
148
|
-
if (!(content instanceof HTMLElement)) {
|
|
149
|
-
throw new Error("scroll pane content element not found")
|
|
150
|
-
}
|
|
151
|
-
return { container, content, onScroll }
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
describe("ScrollPane - pointer drag state robustness", () => {
|
|
155
|
-
it("scrolls on a normal captured drag (baseline)", () => {
|
|
156
|
-
const { content, onScroll } = setupDragPane()
|
|
157
|
-
fireEvent.pointerDown(content, pointerInit(1, 100, 1))
|
|
158
|
-
fireEvent.pointerMove(window, pointerInit(1, 80, 1))
|
|
159
|
-
expect(onScroll).toHaveBeenCalledWith(20, 0)
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
it("discards stale drag state when a pointermove arrives with no buttons pressed (lost pointerup)", () => {
|
|
163
|
-
const { content, onScroll } = setupDragPane()
|
|
164
|
-
// pointerdown 後に pointerup が失われたと想定 (iframe 上での解放や Alt+Tab 等)
|
|
165
|
-
fireEvent.pointerDown(content, pointerInit(1, 100, 1))
|
|
166
|
-
// ボタン非押下の hover 移動はドラッグ化してはならない
|
|
167
|
-
fireEvent.pointerMove(window, pointerInit(1, 50, 0))
|
|
168
|
-
fireEvent.pointerMove(window, pointerInit(1, 20, 0))
|
|
169
|
-
expect(onScroll).not.toHaveBeenCalled()
|
|
170
|
-
// その後の click も握り潰されない
|
|
171
|
-
const click = new MouseEvent("click", { bubbles: true, cancelable: true })
|
|
172
|
-
content.dispatchEvent(click)
|
|
173
|
-
expect(click.defaultPrevented).toBe(false)
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
it("ends the drag when pointer capture is lost (lostpointercapture)", () => {
|
|
177
|
-
const { content, onScroll } = setupDragPane()
|
|
178
|
-
fireEvent.pointerDown(content, pointerInit(1, 100, 1))
|
|
179
|
-
fireEvent.pointerMove(window, pointerInit(1, 80, 1))
|
|
180
|
-
expect(onScroll).toHaveBeenCalledTimes(1)
|
|
181
|
-
// OS/ブラウザ都合の強制キャプチャ解放をシミュレート
|
|
182
|
-
fireEvent.lostPointerCapture(content, { pointerId: 1, bubbles: true })
|
|
183
|
-
fireEvent.pointerMove(window, pointerInit(1, 40, 1))
|
|
184
|
-
expect(onScroll).toHaveBeenCalledTimes(1)
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
it("keeps click suppression when releasePointerCapture fires lostpointercapture synchronously", () => {
|
|
188
|
-
const { content, onScroll } = setupDragPane()
|
|
189
|
-
// releasePointerCapture が lostpointercapture を同期発火するブラウザを模倣する
|
|
190
|
-
let captured = false
|
|
191
|
-
content.setPointerCapture = () => {
|
|
192
|
-
captured = true
|
|
193
|
-
}
|
|
194
|
-
content.hasPointerCapture = () => captured
|
|
195
|
-
content.releasePointerCapture = () => {
|
|
196
|
-
captured = false
|
|
197
|
-
content.dispatchEvent(new window.PointerEvent("lostpointercapture", { pointerId: 1, bubbles: true }))
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
fireEvent.pointerDown(content, pointerInit(1, 100, 1))
|
|
201
|
-
fireEvent.pointerMove(window, pointerInit(1, 80, 1))
|
|
202
|
-
expect(onScroll).toHaveBeenCalledWith(20, 0)
|
|
203
|
-
fireEvent.pointerUp(window, pointerInit(1, 80, 1))
|
|
204
|
-
|
|
205
|
-
// 同期 lostpointercapture (意図的解放) で abortDrag が click 抑止を消してはならない
|
|
206
|
-
const click = new MouseEvent("click", { bubbles: true, cancelable: true })
|
|
207
|
-
content.dispatchEvent(click)
|
|
208
|
-
expect(click.defaultPrevented).toBe(true)
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
it("ignores a second pointerdown while another pointer is actively dragging", () => {
|
|
212
|
-
const { content, onScroll } = setupDragPane()
|
|
213
|
-
fireEvent.pointerDown(content, pointerInit(1, 100, 1))
|
|
214
|
-
fireEvent.pointerMove(window, pointerInit(1, 80, 1))
|
|
215
|
-
expect(onScroll).toHaveBeenCalledWith(20, 0)
|
|
216
|
-
|
|
217
|
-
// 2 本目の pointerdown はドラッグを乗っ取らない (先勝ち)
|
|
218
|
-
fireEvent.pointerDown(content, pointerInit(2, 300, 1))
|
|
219
|
-
fireEvent.pointerMove(window, pointerInit(2, 200, 1))
|
|
220
|
-
expect(onScroll).toHaveBeenCalledTimes(1)
|
|
221
|
-
|
|
222
|
-
// 1 本目のドラッグは継続する
|
|
223
|
-
fireEvent.pointerMove(window, pointerInit(1, 60, 1))
|
|
224
|
-
expect(onScroll).toHaveBeenLastCalledWith(40, 20)
|
|
225
|
-
|
|
226
|
-
// 1 本目解放時の click 抑止も保持されている
|
|
227
|
-
fireEvent.pointerUp(window, pointerInit(1, 60, 1))
|
|
228
|
-
const click = new MouseEvent("click", { bubbles: true, cancelable: true })
|
|
229
|
-
content.dispatchEvent(click)
|
|
230
|
-
expect(click.defaultPrevented).toBe(true)
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
it("does not throw when setPointerCapture fails at drag activation", () => {
|
|
234
|
-
const { content, onScroll } = setupDragPane()
|
|
235
|
-
content.setPointerCapture = () => {
|
|
236
|
-
throw new DOMException("pointer not found", "NotFoundError")
|
|
237
|
-
}
|
|
238
|
-
fireEvent.pointerDown(content, pointerInit(1, 100, 1))
|
|
239
|
-
expect(() => {
|
|
240
|
-
fireEvent.pointerMove(window, pointerInit(1, 80, 1))
|
|
241
|
-
}).not.toThrow()
|
|
242
|
-
// キャプチャ失敗後もドラッグ (スクロール) は継続する
|
|
243
|
-
expect(onScroll).toHaveBeenCalledWith(20, 0)
|
|
244
|
-
})
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
describe("ScrollPane - horizontal wheel delegation vs ctrl+wheel zoom", () => {
|
|
248
|
-
const setupHorizontal = () => {
|
|
249
|
-
const onWheelHorizontal = vi.fn()
|
|
250
|
-
const onScroll = vi.fn()
|
|
251
|
-
const { container } = render(
|
|
252
|
-
<ScrollPane contentSize={1000} viewportSize={200} onScroll={onScroll} onWheelHorizontal={onWheelHorizontal}>
|
|
253
|
-
{() => <div>content</div>}
|
|
254
|
-
</ScrollPane>,
|
|
255
|
-
)
|
|
256
|
-
const pane = container.querySelector(".aqvs-scroll-pane")
|
|
257
|
-
if (!(pane instanceof HTMLElement)) {
|
|
258
|
-
throw new Error("scroll pane element not found")
|
|
259
|
-
}
|
|
260
|
-
return { pane, onScroll, onWheelHorizontal }
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
it("delegates shift+wheel to onWheelHorizontal (baseline)", () => {
|
|
264
|
-
const { pane, onScroll, onWheelHorizontal } = setupHorizontal()
|
|
265
|
-
const event = dispatchWheel(pane, { deltaY: 100, shiftKey: true })
|
|
266
|
-
expect(event.defaultPrevented).toBe(true)
|
|
267
|
-
expect(onWheelHorizontal).toHaveBeenCalledWith(100)
|
|
268
|
-
expect(onScroll).not.toHaveBeenCalled()
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
it("does not intercept ctrl+shift+wheel (browser zoom passes through)", () => {
|
|
272
|
-
const { pane, onScroll, onWheelHorizontal } = setupHorizontal()
|
|
273
|
-
// ctrl+shift+縦ホイールはブラウザズーム: 横委譲に横取りされてはならない
|
|
274
|
-
const event = dispatchWheel(pane, { deltaY: 100, shiftKey: true, ctrlKey: true })
|
|
275
|
-
expect(event.defaultPrevented).toBe(false)
|
|
276
|
-
expect(onWheelHorizontal).not.toHaveBeenCalled()
|
|
277
|
-
expect(onScroll).not.toHaveBeenCalled()
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
it("does not intercept ctrl + horizontal trackpad pan", () => {
|
|
281
|
-
const { pane, onScroll, onWheelHorizontal } = setupHorizontal()
|
|
282
|
-
// ctrl を押しながらの横パンもズーム系操作として素通しする
|
|
283
|
-
const event = dispatchWheel(pane, { deltaX: 100, deltaY: 0, ctrlKey: true })
|
|
284
|
-
expect(event.defaultPrevented).toBe(false)
|
|
285
|
-
expect(onWheelHorizontal).not.toHaveBeenCalled()
|
|
286
|
-
expect(onScroll).not.toHaveBeenCalled()
|
|
287
|
-
})
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
describe("ScrollPane - conditional touchAction", () => {
|
|
291
|
-
it("applies touchAction none while the pane is scrollable", () => {
|
|
292
|
-
const { content } = setupDragPane({ contentSize: 1000, viewportSize: 200 })
|
|
293
|
-
expect(content.style.touchAction).toBe("none")
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
it("keeps the default touchAction when the pane cannot scroll (contentSize <= viewportSize)", () => {
|
|
297
|
-
const { content } = setupDragPane({ contentSize: 100, viewportSize: 200 })
|
|
298
|
-
expect(content.style.touchAction).not.toBe("none")
|
|
299
|
-
})
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
describe("ScrollPane - inertia robustness", () => {
|
|
303
|
-
it("does not start inertia when the pointer was held still before release", () => {
|
|
304
|
-
// 速度サンプルの時刻を制御するため performance.now をモックする
|
|
305
|
-
let now = 0
|
|
306
|
-
vi.spyOn(performance, "now").mockImplementation(() => now)
|
|
307
|
-
const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1)
|
|
308
|
-
const { content } = setupDragPane()
|
|
309
|
-
|
|
310
|
-
fireEvent.pointerDown(content, pointerInit(1, 200, 1))
|
|
311
|
-
now = 16
|
|
312
|
-
fireEvent.pointerMove(window, pointerInit(1, 150, 1))
|
|
313
|
-
now = 32
|
|
314
|
-
fireEvent.pointerMove(window, pointerInit(1, 100, 1))
|
|
315
|
-
// 150ms 静止したまま離す: 古いサンプルによる慣性の誤発火があってはならない
|
|
316
|
-
now = 182
|
|
317
|
-
fireEvent.pointerUp(window, pointerInit(1, 100, 1))
|
|
318
|
-
|
|
319
|
-
expect(rafSpy).not.toHaveBeenCalled()
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
it("starts inertia when released immediately after a fast drag (baseline)", () => {
|
|
323
|
-
let now = 0
|
|
324
|
-
vi.spyOn(performance, "now").mockImplementation(() => now)
|
|
325
|
-
const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1)
|
|
326
|
-
const { content } = setupDragPane()
|
|
327
|
-
|
|
328
|
-
fireEvent.pointerDown(content, pointerInit(1, 200, 1))
|
|
329
|
-
now = 16
|
|
330
|
-
fireEvent.pointerMove(window, pointerInit(1, 150, 1))
|
|
331
|
-
now = 32
|
|
332
|
-
fireEvent.pointerMove(window, pointerInit(1, 100, 1))
|
|
333
|
-
// 素早いドラッグ直後の解放では慣性が発火する
|
|
334
|
-
now = 48
|
|
335
|
-
fireEvent.pointerUp(window, pointerInit(1, 100, 1))
|
|
336
|
-
|
|
337
|
-
expect(rafSpy).toHaveBeenCalled()
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
it("does not re-arm the inertia loop when stopInertia is called synchronously from onScroll", () => {
|
|
341
|
-
let now = 0
|
|
342
|
-
vi.spyOn(performance, "now").mockImplementation(() => now)
|
|
343
|
-
// rAF をキュー化して手動でフレームを進められるようにする
|
|
344
|
-
const rafCallbacks = new Map<number, FrameRequestCallback>()
|
|
345
|
-
let rafId = 0
|
|
346
|
-
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
|
347
|
-
rafId += 1
|
|
348
|
-
rafCallbacks.set(rafId, callback)
|
|
349
|
-
return rafId
|
|
350
|
-
})
|
|
351
|
-
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => {
|
|
352
|
-
rafCallbacks.delete(id)
|
|
353
|
-
})
|
|
354
|
-
const runFrame = (timestamp: number) => {
|
|
355
|
-
const entry = [...rafCallbacks.entries()][0]
|
|
356
|
-
if (!entry) {
|
|
357
|
-
throw new Error("no pending animation frame")
|
|
358
|
-
}
|
|
359
|
-
rafCallbacks.delete(entry[0])
|
|
360
|
-
entry[1](timestamp)
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const ref = createRef<ScrollPaneHandle>()
|
|
364
|
-
let stopOnNextScroll = false
|
|
365
|
-
// 消費者が onScroll から同期的に handle.scrollTo (内部で stopInertia) を呼ぶケース (クランプ/スナップ実装等)
|
|
366
|
-
const onScroll = vi.fn((position: number) => {
|
|
367
|
-
if (stopOnNextScroll) {
|
|
368
|
-
stopOnNextScroll = false
|
|
369
|
-
ref.current?.scrollTo(position)
|
|
370
|
-
}
|
|
371
|
-
})
|
|
372
|
-
const { container } = render(
|
|
373
|
-
<ScrollPane ref={ref} contentSize={1000} viewportSize={200} onScroll={onScroll}>
|
|
374
|
-
{() => <div>content</div>}
|
|
375
|
-
</ScrollPane>,
|
|
376
|
-
)
|
|
377
|
-
const content = container.querySelector(".aqvs-scroll-pane-content")
|
|
378
|
-
if (!(content instanceof HTMLElement)) {
|
|
379
|
-
throw new Error("scroll pane content element not found")
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
fireEvent.pointerDown(content, pointerInit(1, 200, 1))
|
|
383
|
-
now = 16
|
|
384
|
-
fireEvent.pointerMove(window, pointerInit(1, 150, 1))
|
|
385
|
-
now = 32
|
|
386
|
-
fireEvent.pointerMove(window, pointerInit(1, 100, 1))
|
|
387
|
-
now = 48
|
|
388
|
-
fireEvent.pointerUp(window, pointerInit(1, 100, 1))
|
|
389
|
-
// 慣性ループがアームされている
|
|
390
|
-
expect(rafCallbacks.size).toBe(1)
|
|
391
|
-
|
|
392
|
-
// 初回フレームは lastTimestamp の初期化のみで再アームされる
|
|
393
|
-
runFrame(100)
|
|
394
|
-
expect(rafCallbacks.size).toBe(1)
|
|
395
|
-
|
|
396
|
-
// このフレームの scrollTo → onScroll → handle.scrollTo → stopInertia (同期再入)
|
|
397
|
-
stopOnNextScroll = true
|
|
398
|
-
runFrame(116)
|
|
399
|
-
|
|
400
|
-
// 停止指示が尊重され、frame は再アームされない (世代トークンによる自壊)
|
|
401
|
-
expect(rafCallbacks.size).toBe(0)
|
|
402
|
-
})
|
|
403
|
-
})
|
|
404
|
-
|
|
405
|
-
describe("ScrollPane - scrollTo non-finite guard", () => {
|
|
406
|
-
it("keeps the current position and skips onScroll when scrollTo receives NaN", () => {
|
|
407
|
-
const warnImpl = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }
|
|
408
|
-
Logger.setImplementation(warnImpl)
|
|
409
|
-
try {
|
|
410
|
-
const ref = createRef<ScrollPaneHandle>()
|
|
411
|
-
const onScroll = vi.fn()
|
|
412
|
-
const { container } = render(
|
|
413
|
-
<ScrollPane ref={ref} contentSize={1000} viewportSize={200} onScroll={onScroll} initialScrollPosition={100}>
|
|
414
|
-
{() => <div>content</div>}
|
|
415
|
-
</ScrollPane>,
|
|
416
|
-
)
|
|
417
|
-
// NaN は現位置維持 + 警告ログ。onScroll は発火しない
|
|
418
|
-
expect(ref.current?.scrollTo(Number.NaN)).toBe(100)
|
|
419
|
-
expect(onScroll).not.toHaveBeenCalled()
|
|
420
|
-
expect(warnImpl.warn).toHaveBeenCalled()
|
|
421
|
-
|
|
422
|
-
// 関数形式の更新でも同様にガードされる
|
|
423
|
-
expect(ref.current?.scrollTo(() => Number.NaN)).toBe(100)
|
|
424
|
-
expect(onScroll).not.toHaveBeenCalled()
|
|
425
|
-
|
|
426
|
-
// NaN 汚染が無いため、その後の相対スクロール (wheel の prev + delta) は正常に動く
|
|
427
|
-
const pane = container.querySelector(".aqvs-scroll-pane")
|
|
428
|
-
if (!(pane instanceof HTMLElement)) {
|
|
429
|
-
throw new Error("scroll pane element not found")
|
|
430
|
-
}
|
|
431
|
-
dispatchWheel(pane, { deltaY: 50 })
|
|
432
|
-
expect(onScroll).toHaveBeenCalledWith(150, 100)
|
|
433
|
-
} finally {
|
|
434
|
-
Logger.setImplementation(console)
|
|
435
|
-
}
|
|
436
|
-
})
|
|
437
|
-
})
|
|
438
|
-
|
|
439
|
-
describe("ScrollPane - lazy debug logging", () => {
|
|
440
|
-
it("does not read scrollTop in the wheel handler while debug logging is suppressed", () => {
|
|
441
|
-
const { container, content } = setupDragPane()
|
|
442
|
-
const pane = container.querySelector(".aqvs-scroll-pane")
|
|
443
|
-
if (!(pane instanceof HTMLElement)) {
|
|
444
|
-
throw new Error("scroll pane element not found")
|
|
445
|
-
}
|
|
446
|
-
// 抑制時 (既定 WARN) はログ引数のサンクが評価されず、DOM 読み取りも発生しないことを検証する
|
|
447
|
-
let scrollTopReads = 0
|
|
448
|
-
Object.defineProperty(content, "scrollTop", {
|
|
449
|
-
configurable: true,
|
|
450
|
-
get() {
|
|
451
|
-
scrollTopReads++
|
|
452
|
-
return 0
|
|
453
|
-
},
|
|
454
|
-
set() {},
|
|
455
|
-
})
|
|
456
|
-
dispatchWheel(pane, { deltaY: 100 })
|
|
457
|
-
expect(scrollTopReads).toBe(0)
|
|
458
|
-
})
|
|
459
|
-
|
|
460
|
-
it("resolves thunk arguments into plain objects when debug logging is enabled", () => {
|
|
461
|
-
const impl = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }
|
|
462
|
-
Logger.setImplementation(impl)
|
|
463
|
-
Logger.setLevel(LogLevel.DEBUG)
|
|
464
|
-
try {
|
|
465
|
-
const { container } = setupDragPane()
|
|
466
|
-
const pane = container.querySelector(".aqvs-scroll-pane")
|
|
467
|
-
if (!(pane instanceof HTMLElement)) {
|
|
468
|
-
throw new Error("scroll pane element not found")
|
|
469
|
-
}
|
|
470
|
-
impl.debug.mockClear()
|
|
471
|
-
dispatchWheel(pane, { deltaY: 100 })
|
|
472
|
-
const wheelCall = impl.debug.mock.calls.find((call) => typeof call[0] === "string" && call[0].includes("wheel event"))
|
|
473
|
-
expect(wheelCall).toBeDefined()
|
|
474
|
-
// サンクは Logger 内で解決され、実装には生のオブジェクトが渡る
|
|
475
|
-
expect(typeof wheelCall?.[1]).toBe("object")
|
|
476
|
-
expect((wheelCall?.[1] as { deltaY: number }).deltaY).toBe(100)
|
|
477
|
-
} finally {
|
|
478
|
-
Logger.setLevel(LogLevel.WARN)
|
|
479
|
-
Logger.setImplementation(console)
|
|
480
|
-
}
|
|
481
|
-
})
|
|
482
|
-
})
|