@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.
@@ -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?.hasPointerCapture(releasedPointerId)) {
1137
- element.releasePointerCapture(releasedPointerId)
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.hasPointerCapture(event.pointerId)) {
1234
+ if (!isPointerCaptured(element, event.pointerId)) {
1197
1235
  // 注意: キャプチャは pointerdown 時ではなくドラッグ確定時に取得する。
1198
1236
  // pointerdown で即キャプチャすると click がキャプチャ要素へリターゲットされ、
1199
1237
  // ペイン内の子要素 (行など) の click ハンドラが一切発火しなくなる (Chromium 実測)。
1200
1238
  // ドラッグ確定後は shouldCancelNextClick で click を意図的に抑止するため副作用がない。
1201
- try {
1202
- element.setPointerCapture(event.pointerId)
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
- // pointerup を受け取れないままドラッグ継続が不可能になったときの共通終了処理。
1248
- // Shared teardown for cases where the drag can no longer continue without receiving a pointerup.
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
- if (element.hasPointerCapture(pointerId)) {
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
- if (element.hasPointerCapture(event.pointerId)) {
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 && element.hasPointerCapture(state.pointerId)) {
1562
- element.releasePointerCapture(state.pointerId)
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}
@@ -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?.hasPointerCapture(pointerId)) {
214
- element.releasePointerCapture(pointerId)
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
- try {
250
- element.setPointerCapture(event.pointerId)
251
- } catch {
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
- let candidateAlive = true
300
- if (element !== null && typeof element.setPointerCapture === "function") {
301
- try {
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)