@aiquants/virtualscroll 3.7.0 → 3.8.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 +152 -39
- package/README.md +92 -5
- package/dist/ScrollBar.d.cts +35 -1
- package/dist/ScrollBar.d.ts +35 -1
- package/dist/ScrollBar.d.ts.map +1 -1
- package/dist/ScrollPane.d.cts +15 -0
- package/dist/ScrollPane.d.ts +15 -0
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/TapScrollCircle.d.ts.map +1 -1
- package/dist/VirtualGrid.d.cts +70 -57
- package/dist/VirtualGrid.d.ts +70 -57
- package/dist/VirtualGrid.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +59 -2
- package/dist/VirtualScroll.d.ts +59 -2
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/computeTapScrollVelocity.d.cts +3 -3
- package/dist/computeTapScrollVelocity.d.ts +3 -3
- package/dist/index.cjs +1 -1
- package/dist/index.js +2106 -2102
- package/dist/pointerCapture.d.cts +68 -0
- package/dist/pointerCapture.d.ts +69 -0
- package/dist/pointerCapture.d.ts.map +1 -0
- package/dist/useGridTapScroll.d.cts +11 -11
- package/dist/useGridTapScroll.d.ts +11 -11
- package/dist/useGridTapScroll.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ScrollBar.tsx +69 -41
- package/src/ScrollPane.tsx +62 -18
- package/src/TapScrollCircle.tsx +35 -25
- package/src/VirtualGrid.tsx +111 -70
- package/src/VirtualScroll.tsx +167 -5
- package/src/computeTapScrollVelocity.ts +3 -3
- package/src/pointerCapture.ts +89 -0
- package/src/styles/virtualscroll.css +1 -1
- package/src/useGridTapScroll.ts +12 -12
package/src/ScrollPane.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayo
|
|
|
2
2
|
import { twMerge } from "tailwind-merge"
|
|
3
3
|
import type { VirtualScrollLabelOverrides, VirtualScrollLocale } from "./labels.ts"
|
|
4
4
|
import { Logger } from "./logger.ts"
|
|
5
|
+
import { capturePointer, isPointerCaptured, releaseCapturedPointer } from "./pointerCapture.ts"
|
|
5
6
|
import { ScrollBar, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_CANCEL_EVENT } from "./ScrollBar.tsx"
|
|
6
7
|
import { getAxisScale, minmax } from "./utils.ts"
|
|
7
8
|
import { resolveWheelAxes } from "./wheelAxes.ts"
|
|
@@ -78,6 +79,21 @@ export type ScrollPaneProps = {
|
|
|
78
79
|
enableTrackClick?: boolean
|
|
79
80
|
/** Whether arrow buttons control the scroll position. / 矢印ボタンのスクロール操作を許可するかどうか。 */
|
|
80
81
|
enableArrowButtons?: boolean
|
|
82
|
+
/**
|
|
83
|
+
* Whether the scrollbar's two arrow buttons are Tab stops (default `true`), forwarded to the
|
|
84
|
+
* pane's ScrollBar. Set it to `false` when the host already provides keyboard scrolling for this
|
|
85
|
+
* pane: the arrows then only add two redundant Tab stops (native scrollbars are never Tab stops
|
|
86
|
+
* either). `false` changes `tabIndex` alone — the arrows stay pointer-operable and named. The
|
|
87
|
+
* default stays `true` because otherwise they are the pane's only scrolling control that Tab
|
|
88
|
+
* reaches. Full contract: `ScrollBarProps["enableArrowButtonTabStops"]`.
|
|
89
|
+
* スクロールバーの矢印ボタン 2 個を Tab の止まり先にするかどうか (既定 `true`)。ペインの ScrollBar へ
|
|
90
|
+
* 転送。ホストがこのペインのキーボードスクロールを既に提供する場合に `false` — 矢印は冗長な Tab の
|
|
91
|
+
* 止まり先を 2 つ足すだけになるため (ネイティブのスクロールバーも Tab の止まり先にならない)。`false` が
|
|
92
|
+
* 変えるのは `tabIndex` だけで、ポインタ操作とアクセシブルネームは維持。
|
|
93
|
+
* 既定が `true` なのは、それ以外ではペインで Tab の届く唯一のスクロール操作部品だから。契約の全文は
|
|
94
|
+
* `ScrollBarProps["enableArrowButtonTabStops"]`。
|
|
95
|
+
*/
|
|
96
|
+
enableArrowButtonTabStops?: boolean
|
|
81
97
|
/** Whether dragging the content area scrolls the pane. / コンテンツ領域のドラッグでスクロールさせるかどうか。 */
|
|
82
98
|
enablePointerDrag?: boolean
|
|
83
99
|
/**
|
|
@@ -326,6 +342,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
326
342
|
enableThumbDrag = true,
|
|
327
343
|
enableTrackClick = true,
|
|
328
344
|
enableArrowButtons = true,
|
|
345
|
+
enableArrowButtonTabStops = true,
|
|
329
346
|
enablePointerDrag = true,
|
|
330
347
|
pointerDragInputs = DEFAULT_POINTER_DRAG_INPUTS,
|
|
331
348
|
onScroll,
|
|
@@ -1133,11 +1150,22 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1133
1150
|
state.velocitySamples = []
|
|
1134
1151
|
state.scale = 1
|
|
1135
1152
|
// 状態を確定させてから解放する (同期 lostpointercapture に引き継ぎを壊させない)
|
|
1136
|
-
if (element
|
|
1137
|
-
element
|
|
1153
|
+
if (element !== null) {
|
|
1154
|
+
releaseCapturedPointer(element, releasedPointerId)
|
|
1138
1155
|
}
|
|
1139
1156
|
}, [enablePointerDrag, pointerDragInputs, isScrollable])
|
|
1140
1157
|
|
|
1158
|
+
/**
|
|
1159
|
+
* Wires the pointer drag-scroll of the content area and tears it down on unmount.
|
|
1160
|
+
* コンテンツ領域のポインタドラッグスクロールを結線し、アンマウント時に解除する処理。
|
|
1161
|
+
*
|
|
1162
|
+
* キャプチャの取得・解放・保持確認はすべて `pointerCapture.ts` の能力判定を通す。
|
|
1163
|
+
* Pointer Capture API の無い環境 (jsdom 等) でもドラッグは `window` のリスナーで追従し、例外は投げない。
|
|
1164
|
+
*
|
|
1165
|
+
* 目的: ドラッグ・慣性・click 抑止のリスナーをコンテンツ要素と `window` へ登録する。
|
|
1166
|
+
* 依存関係: [id]
|
|
1167
|
+
* クリーンアップ: リスナーを外し、保持中のキャプチャを解放してドラッグ状態を初期化する。
|
|
1168
|
+
*/
|
|
1141
1169
|
useEffect(() => {
|
|
1142
1170
|
const element = contentAreaRef.current
|
|
1143
1171
|
if (!element) {
|
|
@@ -1186,6 +1214,16 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1186
1214
|
state.shouldCancelNextClick = false
|
|
1187
1215
|
}
|
|
1188
1216
|
|
|
1217
|
+
/**
|
|
1218
|
+
* Confirms the drag once the activation threshold is crossed and moves the capture onto the content area.
|
|
1219
|
+
* 発動閾値を跨いだ時点でドラッグを確定し、キャプチャをコンテンツ領域へ移す処理。
|
|
1220
|
+
*
|
|
1221
|
+
* キャプチャを取れない場合 (非アクティブなポインタ・Pointer Capture API の非対応環境) も
|
|
1222
|
+
* ドラッグは確定させる。追従は `window` の `pointermove` が受け持つため、キャプチャが無くても止まらない。
|
|
1223
|
+
*
|
|
1224
|
+
* @param event The pointer move that crossed the threshold. 閾値を跨いだ pointermove。
|
|
1225
|
+
* @returns Nothing. なし。
|
|
1226
|
+
*/
|
|
1189
1227
|
const startDragging = (event: PointerEvent) => {
|
|
1190
1228
|
const state = dragStateRef.current
|
|
1191
1229
|
if (state.isDragging) {
|
|
@@ -1193,16 +1231,13 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1193
1231
|
}
|
|
1194
1232
|
state.isDragging = true
|
|
1195
1233
|
state.shouldCancelNextClick = true
|
|
1196
|
-
if (!element
|
|
1234
|
+
if (!isPointerCaptured(element, event.pointerId)) {
|
|
1197
1235
|
// 注意: キャプチャは pointerdown 時ではなくドラッグ確定時に取得する。
|
|
1198
1236
|
// pointerdown で即キャプチャすると click がキャプチャ要素へリターゲットされ、
|
|
1199
1237
|
// ペイン内の子要素 (行など) の click ハンドラが一切発火しなくなる (Chromium 実測)。
|
|
1200
1238
|
// ドラッグ確定後は shouldCancelNextClick で click を意図的に抑止するため副作用がない。
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
} catch {
|
|
1204
|
-
// ポインタが既に非アクティブだと NotFoundError になり得る。キャプチャ無しでもドラッグは継続可能。
|
|
1205
|
-
}
|
|
1239
|
+
// 取得に失敗してもキャプチャ無しでドラッグは継続できるため、結果は見ない。
|
|
1240
|
+
capturePointer(element, event.pointerId)
|
|
1206
1241
|
}
|
|
1207
1242
|
pushVelocitySample(event.clientY)
|
|
1208
1243
|
}
|
|
@@ -1244,13 +1279,16 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1244
1279
|
*/
|
|
1245
1280
|
const isAuthoritativeForDrag = (event: PointerEvent) => event.isTrusted || event.target === element
|
|
1246
1281
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1282
|
+
/**
|
|
1283
|
+
* Shared teardown for cases where the drag can no longer continue without receiving a pointerup.
|
|
1284
|
+
* pointerup を受け取れないままドラッグ継続が不可能になったときの共通終了処理。
|
|
1285
|
+
*
|
|
1286
|
+
* @param pointerId The pointer whose drag is aborted. ドラッグを打ち切るポインタ ID。
|
|
1287
|
+
* @returns Nothing. なし。
|
|
1288
|
+
*/
|
|
1249
1289
|
const abortDrag = (pointerId: number) => {
|
|
1250
1290
|
const state = dragStateRef.current
|
|
1251
|
-
|
|
1252
|
-
element.releasePointerCapture(pointerId)
|
|
1253
|
-
}
|
|
1291
|
+
releaseCapturedPointer(element, pointerId)
|
|
1254
1292
|
state.shouldCancelNextClick = false
|
|
1255
1293
|
clearClickTimer()
|
|
1256
1294
|
resetDragState()
|
|
@@ -1337,6 +1375,13 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1337
1375
|
return true
|
|
1338
1376
|
}
|
|
1339
1377
|
|
|
1378
|
+
/**
|
|
1379
|
+
* Ends the drag on pointerup, arms the click suppression and hands the fling velocity to inertia.
|
|
1380
|
+
* pointerup でドラッグを終え、click 抑止を張り、フリック速度を慣性へ渡す処理。
|
|
1381
|
+
*
|
|
1382
|
+
* @param event The terminal pointerup. 終端の pointerup。
|
|
1383
|
+
* @returns Nothing. なし。
|
|
1384
|
+
*/
|
|
1340
1385
|
const handlePointerUp = (event: PointerEvent) => {
|
|
1341
1386
|
const state = dragStateRef.current
|
|
1342
1387
|
if (state.pointerId !== event.pointerId) {
|
|
@@ -1394,9 +1439,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1394
1439
|
// handleLostPointerCapture は pointerId 不一致で無視されるため、abortDrag による
|
|
1395
1440
|
// shouldCancelNextClick / clickResetTimer の破棄 (click 抑止の喪失) が起きない。
|
|
1396
1441
|
resetDragState()
|
|
1397
|
-
|
|
1398
|
-
element.releasePointerCapture(event.pointerId)
|
|
1399
|
-
}
|
|
1442
|
+
releaseCapturedPointer(element, event.pointerId)
|
|
1400
1443
|
|
|
1401
1444
|
if (Math.abs(inertiaVelocity) >= threshold) {
|
|
1402
1445
|
startInertiaRef.current?.(inertiaVelocity)
|
|
@@ -1558,8 +1601,8 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1558
1601
|
toggleListeners(element, elementEntries, "remove")
|
|
1559
1602
|
toggleListeners(window, windowEntries, "remove")
|
|
1560
1603
|
const state = dragStateRef.current
|
|
1561
|
-
if (state.pointerId !== null
|
|
1562
|
-
element
|
|
1604
|
+
if (state.pointerId !== null) {
|
|
1605
|
+
releaseCapturedPointer(element, state.pointerId)
|
|
1563
1606
|
}
|
|
1564
1607
|
clearClickTimer()
|
|
1565
1608
|
resetDragState()
|
|
@@ -1648,6 +1691,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
|
|
|
1648
1691
|
enableThumbDrag={enableThumbDrag}
|
|
1649
1692
|
enableTrackClick={enableTrackClick}
|
|
1650
1693
|
enableArrowButtons={enableArrowButtons}
|
|
1694
|
+
enableArrowButtonTabStops={enableArrowButtonTabStops}
|
|
1651
1695
|
scrollBarWidth={scrollBarWidth}
|
|
1652
1696
|
ariaControls={contentId}
|
|
1653
1697
|
tapScrollCircleOptions={tapScrollCircleOptions}
|
package/src/TapScrollCircle.tsx
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from "react"
|
|
7
7
|
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from "react"
|
|
8
8
|
import { twMerge } from "tailwind-merge"
|
|
9
|
+
import { capturePointer, releaseCapturedPointer } from "./pointerCapture.ts"
|
|
9
10
|
|
|
10
11
|
export type TapScrollCircleDragState = {
|
|
11
12
|
/** Whether the pointer is actively dragging. / ポインターがドラッグ中かどうか。 */
|
|
@@ -205,13 +206,20 @@ export const TapScrollCircle = memo(
|
|
|
205
206
|
[applyDragState, axis],
|
|
206
207
|
)
|
|
207
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Releases the root's capture of the pointer when it holds one.
|
|
211
|
+
* ルートが指定ポインタのキャプチャを保持していれば解放する処理。
|
|
212
|
+
*
|
|
213
|
+
* @param pointerId The pointer to release, or null when no pointer is tracked. 解放するポインタ ID。追跡中のポインタが無ければ null。
|
|
214
|
+
* @returns Nothing. なし。
|
|
215
|
+
*/
|
|
208
216
|
const releasePointerCapture = useCallback((pointerId: number | null) => {
|
|
209
217
|
if (pointerId === null) {
|
|
210
218
|
return
|
|
211
219
|
}
|
|
212
220
|
const element = rootRef.current
|
|
213
|
-
if (element
|
|
214
|
-
element
|
|
221
|
+
if (element !== null) {
|
|
222
|
+
releaseCapturedPointer(element, pointerId)
|
|
215
223
|
}
|
|
216
224
|
}, [])
|
|
217
225
|
|
|
@@ -228,6 +236,15 @@ export const TapScrollCircle = memo(
|
|
|
228
236
|
[applyDragState, releasePointerCapture],
|
|
229
237
|
)
|
|
230
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Starts a circle drag, or records a second pointer as the hand-off candidate during a drag.
|
|
241
|
+
* サークルのドラッグを開始する処理。ドラッグ中に押された別ポインタはハンドオフ候補として記録する。
|
|
242
|
+
*
|
|
243
|
+
* キャプチャを取得できなくても (非アクティブなポインタ・Pointer Capture API の非対応環境) ドラッグは開始する。
|
|
244
|
+
*
|
|
245
|
+
* @param event The pointerdown on the circle. サークル上の pointerdown。
|
|
246
|
+
* @returns Nothing. なし。
|
|
247
|
+
*/
|
|
231
248
|
const handlePointerDown = useCallback(
|
|
232
249
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
233
250
|
// ドラッグ中に発生した別ポインター (2 本目のタッチ) は待機候補として記録し、
|
|
@@ -246,14 +263,9 @@ export const TapScrollCircle = memo(
|
|
|
246
263
|
const { left, top, width, height } = element.getBoundingClientRect()
|
|
247
264
|
centerRef.current = { x: left + width / 2, y: top + height / 2 }
|
|
248
265
|
pointerIdRef.current = event.pointerId
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// ポインタが既に非アクティブ (ウインドウ外へ出た等) だと setPointerCapture は
|
|
253
|
-
// NotFoundError を投げ得る。キャプチャ無しでもドラッグ処理は継続できるため握り潰す。
|
|
254
|
-
// setPointerCapture can throw NotFoundError if the pointer is already inactive
|
|
255
|
-
// (e.g. moved outside the browser window); dragging still works without capture.
|
|
256
|
-
}
|
|
266
|
+
// ポインタが既に非アクティブ (ウインドウ外へ出た等) でも API 非対応でも、
|
|
267
|
+
// キャプチャ無しでドラッグ処理は継続できるため結果は見ない。
|
|
268
|
+
capturePointer(element, event.pointerId)
|
|
257
269
|
updateDragState(event.clientX, event.clientY, true)
|
|
258
270
|
},
|
|
259
271
|
[updateDragState],
|
|
@@ -274,6 +286,16 @@ export const TapScrollCircle = memo(
|
|
|
274
286
|
[updateDragState],
|
|
275
287
|
)
|
|
276
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Ends the circle drag on pointerup, handing control to a live pending pointer when one exists.
|
|
291
|
+
* pointerup でサークルのドラッグを終える処理。生きている待機候補ポインタがあれば制御を移譲する。
|
|
292
|
+
*
|
|
293
|
+
* 候補の生死は `capturePointer` の結果で判定する。`"inactive-pointer"` だけを死とみなし、
|
|
294
|
+
* API 非対応・それ以外の拒否は生死不明として移譲を続ける。
|
|
295
|
+
*
|
|
296
|
+
* @param event The pointerup or pointercancel on the circle. サークル上の pointerup / pointercancel。
|
|
297
|
+
* @returns Nothing. なし。
|
|
298
|
+
*/
|
|
277
299
|
const handlePointerUp = useCallback(
|
|
278
300
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
279
301
|
if (pointerIdRef.current !== event.pointerId) {
|
|
@@ -296,21 +318,9 @@ export const TapScrollCircle = memo(
|
|
|
296
318
|
// pointers, so a dead candidate resets the state instead of getting promoted.
|
|
297
319
|
releasePointerCapture(event.pointerId)
|
|
298
320
|
const element = rootRef.current
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
element.setPointerCapture(pending.pointerId)
|
|
303
|
-
} catch (error) {
|
|
304
|
-
// NotFoundError = 候補ポインタが既に非アクティブ (サークル外で解放済み等)。
|
|
305
|
-
// クロスレルム (iframe への portal 等) では DOMException の instanceof が
|
|
306
|
-
// false になるため、name ベースで判定する (DOMException も name を持つので包含)。
|
|
307
|
-
// それ以外 (キャプチャ未実装環境等) は生死不明のため移譲を継続する。
|
|
308
|
-
const errorName = typeof error === "object" && error !== null && "name" in error ? (error as { name?: unknown }).name : undefined
|
|
309
|
-
if (errorName === "NotFoundError") {
|
|
310
|
-
candidateAlive = false
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
321
|
+
// NotFoundError (= サークル外で解放済み等) だけが死の証拠。API 非対応やそれ以外の拒否は
|
|
322
|
+
// 生死不明のため移譲を継続する (候補を捨てると 2 本目の指が効かなくなる)。
|
|
323
|
+
const candidateAlive = element === null || capturePointer(element, pending.pointerId) !== "inactive-pointer"
|
|
314
324
|
if (candidateAlive) {
|
|
315
325
|
pointerIdRef.current = pending.pointerId
|
|
316
326
|
updateDragState(pending.clientX, pending.clientY, true)
|