@aiquants/virtualscroll 3.9.2 → 3.11.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +135 -40
  3. package/dist/ScrollBar.d.cts +45 -38
  4. package/dist/ScrollBar.d.ts +45 -38
  5. package/dist/ScrollBar.d.ts.map +1 -1
  6. package/dist/ScrollPane.d.cts +12 -14
  7. package/dist/ScrollPane.d.ts +12 -14
  8. package/dist/ScrollPane.d.ts.map +1 -1
  9. package/dist/VirtualGrid.d.cts +6 -6
  10. package/dist/VirtualGrid.d.ts +6 -6
  11. package/dist/VirtualGrid.d.ts.map +1 -1
  12. package/dist/VirtualScroll.d.cts +184 -30
  13. package/dist/VirtualScroll.d.ts +184 -30
  14. package/dist/VirtualScroll.d.ts.map +1 -1
  15. package/dist/cli.js +26 -47
  16. package/dist/index.cjs +1 -1
  17. package/dist/index.js +2385 -2413
  18. package/dist/labels.d.cts +38 -7
  19. package/dist/labels.d.ts +38 -7
  20. package/dist/labels.d.ts.map +1 -1
  21. package/dist/logger.d.cts +1 -16
  22. package/dist/logger.d.ts +1 -16
  23. package/dist/logger.d.ts.map +1 -1
  24. package/dist/styles/virtualscroll.css +1 -1
  25. package/dist/styles/virtualscroll.standalone.css +1 -1
  26. package/dist/useFenwickMapTree.d.cts +21 -1
  27. package/dist/useFenwickMapTree.d.ts +21 -1
  28. package/dist/useFenwickMapTree.d.ts.map +1 -1
  29. package/dist/useLruCache.d.ts.map +1 -1
  30. package/dist/utils.d.cts +14 -0
  31. package/dist/utils.d.ts +14 -0
  32. package/dist/utils.d.ts.map +1 -1
  33. package/package.json +5 -2
  34. package/src/ScrollBar.tsx +115 -55
  35. package/src/ScrollPane.tsx +12 -14
  36. package/src/VirtualGrid.tsx +27 -25
  37. package/src/VirtualScroll.tsx +486 -234
  38. package/src/labels.ts +58 -7
  39. package/src/logger.ts +1 -25
  40. package/src/styles/virtualscroll.css +51 -3
  41. package/src/useFenwickMapTree.ts +60 -98
  42. package/src/useLruCache.ts +14 -8
  43. package/src/utils.ts +15 -0
package/src/ScrollBar.tsx CHANGED
@@ -7,11 +7,11 @@ import type { CSSProperties, ReactNode } from "react"
7
7
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
8
8
  import { twMerge } from "tailwind-merge"
9
9
  import { snapToDevicePixelGrid, usePaintingDevicePixelRatio } from "./devicePixelGrid.ts"
10
- import { resolveVirtualScrollLabels, type VirtualScrollLabelOverrides, type VirtualScrollLocale } from "./labels.ts"
10
+ import { resolveVirtualScrollLabels, resolveVirtualScrollLocale, type VirtualScrollLabelOverrides, type VirtualScrollLabels, type VirtualScrollLocale } from "./labels.ts"
11
11
  import { Logger } from "./logger.ts"
12
12
  import { capturePointer, releaseCapturedPointer } from "./pointerCapture.ts"
13
13
  import { TapScrollCircle, type TapScrollCircleDragState, type TapScrollCircleHandle, type TapScrollCircleRenderProps } from "./TapScrollCircle.tsx"
14
- import { getAxisScale, minmax } from "./utils.ts"
14
+ import { getAxisScale, keepFocusOnPress, minmax } from "./utils.ts"
15
15
 
16
16
  /**
17
17
  * Configuration for scrollbar orientation (vertical or horizontal).
@@ -40,6 +40,13 @@ type ArrowAutoRepeatOptions = {
40
40
  canUseArrowButtons: boolean
41
41
  /** Whether arrow buttons are enabled in configuration / 設定で矢印ボタンが有効化されているか */
42
42
  enableArrowButtons: boolean
43
+ /**
44
+ * Whether a focused arrow takes Enter / Space: `false` under the pointer-only signal (`enableArrowButtonTabStops: false`),
45
+ * where the bar is hidden from assistive technology and its arrows answer the pointer only, even when a script focuses one.
46
+ * フォーカスした矢印が Enter / Space を受けるか。ポインタ専用の合図 (`enableArrowButtonTabStops: false`) の下では `false` で、
47
+ * バーは支援技術から隠れ、矢印はスクリプトがフォーカスしてもポインタにだけ応える。
48
+ */
49
+ keyboardOperable: boolean
43
50
  /** Function to reset tap scroll state / タップスクロール状態をリセットする関数 */
44
51
  resetTapScroll: () => void
45
52
  /** Function to scroll by a step / ステップ単位でスクロールする関数 */
@@ -154,8 +161,8 @@ export type ScrollBarProps = {
154
161
  viewportSize: number
155
162
  /** The current scroll position. / 現在のスクロール位置。 */
156
163
  scrollPosition: number
157
- /** A callback function invoked to adjust scroll position. Accepts next position or updater. / スクロール位置を調整するためのコールバック。次位置または更新関数を受け取る。 */
158
- onScroll?: (scrollPosition: number | ((prevPosition: number) => number), prevPosition?: number) => number | undefined
164
+ /** A callback function invoked to adjust scroll position. Accepts next position or updater, and always receives the position the bar resolved the request from. / スクロール位置を調整するためのコールバック。次位置または更新関数と、バーが要求を解いた元の位置 (必ず渡す) を受け取る。 */
165
+ onScroll?: (scrollPosition: number | ((prevPosition: number) => number), prevPosition: number) => number | undefined
159
166
  /** Whether grabbing the scrollbar thumb is allowed. / スクロールバーのつまみ操作を許可するかどうか。 */
160
167
  enableThumbDrag?: boolean
161
168
  /** Whether clicking on the track moves the scrollbar. / スクロールバーのトラッククリック操作を許可するかどうか。 */
@@ -163,37 +170,41 @@ export type ScrollBarProps = {
163
170
  /** Whether arrow buttons control the scroll position. / 矢印ボタンによるスクロール操作を許可するかどうか。 */
164
171
  enableArrowButtons?: boolean
165
172
  /**
166
- * Whether the two arrow buttons are Tab stops (default `true`).
173
+ * Whether the bar is the keyboard user's scrolling control (default `true`), or a pointer-only control because the host
174
+ * scrolls this viewport by keyboard itself (`false`).
175
+ *
176
+ * `true`: the bar is exposed to assistive technology as a named `role="scrollbar"` (`labels.verticalScrollBar` /
177
+ * `labels.horizontalScrollBar`) holding a named `role="slider"` thumb (`labels.verticalScrollThumb` /
178
+ * `labels.horizontalScrollThumb`), and its two arrow buttons are Tab stops: `ScrollPane` / `VirtualScroll` move their
179
+ * content by transform, so there is no native scroller the browser could make focusable, and their rows are not Tab
180
+ * stops — without a host keyboard model the arrows are the only scrolling control Tab reaches. Focus an arrow and
181
+ * Enter / Space scrolls one step.
182
+ *
183
+ * `false` declares that the host owns keyboard scrolling of this viewport (roving row focus with Arrow / Page / Home /
184
+ * End, a grid keyboard model), so the bar only duplicates that path. The bar then becomes what a native scrollbar is:
185
+ * a pointer-only control. The bar carries `aria-hidden="true"`; its arrows get `tabIndex={-1}`, and the bar root and
186
+ * the thumb wrapper are not focusable; a press anywhere on the bar cancels its default action, so it never moves
187
+ * focus — neither onto a part of the bar (an arrow, also a disabled one) nor away from where the host put it — while
188
+ * the press itself still reaches the part. Pointer scrolling is unchanged: the arrows (with press-and-hold repeat),
189
+ * the track, the thumb and the tap circle work exactly as with `true`. The same signal reaches the scroll-to-edge pills
190
+ * of `VirtualScroll` (`VirtualScrollScrollBarOptions["enableArrowButtonTabStops"]`).
167
191
  *
168
- * `false` renders both with `tabIndex={-1}`, which takes them out of the sequential focus order
169
- * and changes nothing else: pointer presses and press-and-hold repeat, the accessible names and
170
- * Enter / Space on an arrow focused from script all keep working. The arrows are descendants of
171
- * the `role="scrollbar"` root, whose children ARIA 1.2 makes presentational, so whether assistive
172
- * technology lists them as buttons of their own is the browser's choice (Chromium does); this
173
- * option changes nothing about that. Set it to `false` when the host already owns keyboard
174
- * scrolling of this viewport (roving row focus with Arrow / Page / Home / End, a grid keyboard
175
- * model): the arrows then duplicate that path and cost every keyboard user two extra Tab presses
176
- * per bar — native scrollbars are never Tab stops either. The default stays `true` because
177
- * without such a host model the arrows are the only scrolling control a keyboard user can reach
178
- * with Tab: `ScrollPane` / `VirtualScroll` move their content by transform, so there is no native
179
- * scroller the browser could make focusable, and their rows are not Tab stops. No effect while
180
- * the arrows are disabled (`enableArrowButtons: false`, or nothing to scroll) — a disabled button
181
- * is never focusable.
192
+ * バーがキーボード利用者のスクロール操作部品か (既定 `true`)、ホストがこのビューポートのキーボードスクロールを自分で
193
+ * 持つためポインタ専用の部品か (`false`) の指定。
182
194
  *
183
- * 矢印ボタン 2 個を Tab の止まり先にするかどうか (既定 `true`)。
195
+ * `true`: バーは名前付きの `role="scrollbar"` (`labels.verticalScrollBar` / `labels.horizontalScrollBar`) として
196
+ * 支援技術へ見え、名前付きの `role="slider"` のつまみ (`labels.verticalScrollThumb` / `labels.horizontalScrollThumb`) を
197
+ * 持ち、矢印ボタン 2 個は Tab の止まり先。`ScrollPane` / `VirtualScroll` はコンテンツを transform で動かすため、
198
+ * ブラウザがフォーカス可能にできるネイティブのスクロール領域が無く、行も Tab の止まり先ではない — ホストのキーボード
199
+ * モデルが無ければ、矢印が Tab で届く唯一のスクロール操作部品。矢印にフォーカスして Enter / Space で 1 ステップ移動。
184
200
  *
185
- * `false` は両方を `tabIndex={-1}` で描画し、順次フォーカス移動の順序から外すだけの指定。
186
- * ポインタ押下と長押しリピート、アクセシブルネーム、スクリプトからフォーカスした矢印での
187
- * Enter / Space はすべて維持。矢印は `role="scrollbar"` のルートの子孫で、ARIA 1.2 はその子を
188
- * presentational とするため、支援技術が矢印を独立したボタンとして示すかどうかはブラウザの選択
189
- * (Chromium は示す) であり、本オプションはそこに関与しない。ホストがこのビューポートのキーボード
190
- * スクロールを既に持つ場合 (行のロービングフォーカスと矢印 / Page / Home / End、グリッドのキーボードモデル) に
191
- * `false` を指定 — 矢印はその経路の重複となり、キーボード利用者にバー 1 本あたり 2 回の余分な Tab を
192
- * 課すため (ネイティブのスクロールバーも Tab の止まり先にならない)。既定を `true` に据え置く理由は、
193
- * ホスト側のモデルが無ければ矢印がキーボード利用者の Tab で届く唯一のスクロール操作部品であること
194
- * (`ScrollPane` / `VirtualScroll` はコンテンツを transform で動かすため、ブラウザがフォーカス可能に
195
- * できるネイティブのスクロール領域が無く、行も Tab の止まり先ではない)。矢印が無効な間
196
- * (`enableArrowButtons: false` またはスクロール不要) は効果なし — disabled のボタンはそもそもフォーカス不能。
201
+ * `false` は、ホストがこのビューポートのキーボードスクロールを持つ (行のロービングフォーカスと矢印 / Page / Home / End、
202
+ * グリッドのキーボードモデル) ことの宣言で、バーはその経路の重複にすぎない。バーはネイティブのスクロールバーと同じく
203
+ * ポインタ専用の部品になる。バーは `aria-hidden="true"` を持ち、矢印は `tabIndex={-1}`、バーのルートとつまみの器は
204
+ * フォーカス不能。バーのどこを押しても押下の既定動作を取り消すので、押下はフォーカスを動かさない — バーの部品
205
+ * (矢印。無効の矢印も) へも、ホストが置いた場所の外へも — が、押下そのものは部品へ届く。ポインタのスクロールは
206
+ * 変わらない: 矢印 (長押しの連続を含む)・トラック・つまみ・タップのサークルは `true` と同じに動く。同じ合図は
207
+ * `VirtualScroll` の端へ戻るピルにも届く (`VirtualScrollScrollBarOptions["enableArrowButtonTabStops"]`)。
197
208
  */
198
209
  enableArrowButtonTabStops?: boolean
199
210
  /** Whether the scrollbar is horizontal. / スクロールバーが水平かどうか。 */
@@ -231,18 +242,21 @@ export type ScrollBarProps = {
231
242
  /** The index of the last visible item. / 最後の可視アイテムのインデックス。 */
232
243
  visibleEndIndex?: number
233
244
  /**
234
- * UI chrome locale of the built-in arrow aria-labels (default `"en"`). An unsupported value
235
- * throws a RangeError at render; no language negotiation happens.
236
- * 内蔵矢印 aria-label の UI クロームロケール (既定 `"en"`)。非対応値は描画時に RangeError、
237
- * 言語ネゴシエーションなし。
245
+ * UI chrome locale of the built-in accessible names of the bar, its thumb and its arrows, and of the percentage the bar
246
+ * and the thumb announce as their value text (default `"en"`). An unsupported value throws a RangeError at render; no
247
+ * language negotiation happens.
248
+ * バー・つまみ・矢印の内蔵アクセシブルネームと、バーとつまみが値の文字として知らせる百分率の UI クロームロケール (既定
249
+ * `"en"`)。非対応値は描画時に RangeError、言語ネゴシエーションなし。
238
250
  */
239
251
  locale?: VirtualScrollLocale
240
252
  /**
241
- * Per-key overrides laid over the catalog of `locale`. This bar reads `scrollUp` / `scrollDown`
242
- * (vertical) or `scrollLeft` / `scrollRight` (horizontal); unknown keys and blank values throw a
253
+ * Per-key overrides laid over the catalog of `locale`. A vertical bar reads `verticalScrollBar`,
254
+ * `verticalScrollThumb`, `scrollUp` and `scrollDown`; a horizontal bar reads `horizontalScrollBar`,
255
+ * `horizontalScrollThumb`, `scrollLeft` and `scrollRight`. Unknown keys and blank values throw a
243
256
  * RangeError at render.
244
- * `locale` のカタログへ重ねるキー単位の上書き。本バーが読むのは縦なら `scrollUp` / `scrollDown`、
245
- * 横なら `scrollLeft` / `scrollRight`。未知キーと空白値は描画時に RangeError。
257
+ * `locale` のカタログへ重ねるキー単位の上書き。縦のバーが読むのは `verticalScrollBar`・`verticalScrollThumb`・
258
+ * `scrollUp`・`scrollDown`、横のバーは `horizontalScrollBar`・`horizontalScrollThumb`・`scrollLeft`・`scrollRight`。
259
+ * 未知キーと空白値は描画時に RangeError。
246
260
  */
247
261
  labels?: VirtualScrollLabelOverrides
248
262
  }
@@ -294,6 +308,33 @@ export const DEFAULT_TAP_SCROLL_CIRCLE_OPTIONS: ResolvedTapScrollCircleOptions =
294
308
  maxSpeedCurve: undefined,
295
309
  }
296
310
 
311
+ /**
312
+ * The accessible names one bar renders: the bar (`role="scrollbar"`), its thumb (`role="slider"`) and its two arrows
313
+ * (start, end).
314
+ *
315
+ * バー 1 本が描くアクセシブルネーム (バー `role="scrollbar"`・つまみ `role="slider"`・始端と終端の矢印)。
316
+ */
317
+ type ScrollBarNames = {
318
+ /** Name of the bar / バーの名前 */
319
+ readonly bar: string
320
+ /** Name of the thumb / つまみの名前 */
321
+ readonly thumb: string
322
+ /** Names of the start and the end arrow / 始端と終端の矢印の名前 */
323
+ readonly arrows: readonly [string, string]
324
+ }
325
+
326
+ /**
327
+ * Picks the names of one bar from the resolved labels by its orientation.
328
+ *
329
+ * 解決済みのラベルから、向きに応じてバー 1 本の名前を選ぶ処理。
330
+ *
331
+ * @param labels - Resolved labels / 解決済みのラベル
332
+ * @param horizontal - Whether the bar is horizontal / 横のバーか
333
+ * @returns The bar's names / バーの名前
334
+ */
335
+ const pickScrollBarNames = (labels: VirtualScrollLabels, horizontal: boolean): ScrollBarNames =>
336
+ horizontal ? { bar: labels.horizontalScrollBar, thumb: labels.horizontalScrollThumb, arrows: [labels.scrollLeft, labels.scrollRight] } : { bar: labels.verticalScrollBar, thumb: labels.verticalScrollThumb, arrows: [labels.scrollUp, labels.scrollDown] }
337
+
297
338
  /**
298
339
  * Creates the orientation dependent configuration for the scrollbar.
299
340
  *
@@ -374,7 +415,7 @@ const useThumbVisualFeedback = ({ isDragging, isThumbHovered, enableThumbDrag }:
374
415
  *
375
416
  * スクロールバー矢印の自動リピート操作向けハンドラーを提供。
376
417
  */
377
- const useArrowAutoRepeat = ({ canUseArrowButtons, enableArrowButtons, resetTapScroll, scrollByStep }: ArrowAutoRepeatOptions): ArrowAutoRepeatHandlers => {
418
+ const useArrowAutoRepeat = ({ canUseArrowButtons, enableArrowButtons, keyboardOperable, resetTapScroll, scrollByStep }: ArrowAutoRepeatOptions): ArrowAutoRepeatHandlers => {
378
419
  const arrowHoldIntervalRef = useRef<number | null>(null)
379
420
  const arrowHoldTimeoutRef = useRef<number | null>(null)
380
421
  // 長押し中に張るグローバル解除リスナ (disabled 化やウィンドウ blur でボタンに pointerup が届かない保険)。
@@ -430,7 +471,9 @@ const useArrowAutoRepeat = ({ canUseArrowButtons, enableArrowButtons, resetTapSc
430
471
 
431
472
  const handleArrowKeyDown = useCallback(
432
473
  (direction: 1 | -1) => (event: React.KeyboardEvent<HTMLButtonElement>) => {
433
- if (!enableArrowButtons) {
474
+ // ポインタ専用のバーは支援技術から隠れていて、キーボードのスクロールはホストが持つ。スクリプトがフォーカスした矢印でも
475
+ // キーを受けると、隠れた部品がホストのキー操作と重なって一覧を動かす
476
+ if (!(enableArrowButtons && keyboardOperable)) {
434
477
  return
435
478
  }
436
479
  if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
@@ -438,7 +481,7 @@ const useArrowAutoRepeat = ({ canUseArrowButtons, enableArrowButtons, resetTapSc
438
481
  scrollByStep(direction)
439
482
  }
440
483
  },
441
- [enableArrowButtons, scrollByStep],
484
+ [enableArrowButtons, keyboardOperable, scrollByStep],
442
485
  )
443
486
 
444
487
  // 矢印ボタンが操作不能 (canUseArrowButtons=false) になったら進行中のリピートを即停止する。
@@ -695,7 +738,11 @@ export const ScrollBar = ({
695
738
  const resolvedTapScrollOptions = useMemo<ResolvedTapScrollCircleOptions>(() => resolveTapScrollCircleOptions(tapScrollCircleOptions, itemCount), [itemCount, tapScrollCircleOptions])
696
739
  const orientationConfig = useMemo(() => createOrientationConfig(horizontal), [horizontal])
697
740
  const resolvedLabels = useMemo(() => resolveVirtualScrollLabels(locale, labels), [locale, labels])
698
- const arrowLabels = horizontal ? ([resolvedLabels.scrollLeft, resolvedLabels.scrollRight] as const) : ([resolvedLabels.scrollUp, resolvedLabels.scrollDown] as const)
741
+ const names = pickScrollBarNames(resolvedLabels, horizontal)
742
+ // 値の文字はスクロールの割合。px の値 (aria-valuenow) は長い一覧で数千万になり、読み上げても位置が分からない
743
+ const valueTextFormat = useMemo(() => new Intl.NumberFormat(resolveVirtualScrollLocale(locale), { style: "percent", maximumFractionDigits: 0 }), [locale])
744
+ // ホストがキーボードのスクロールを持つと、バーはその経路の重複になる。支援技術にもキーボードにも同じ操作を 2 度見せない
745
+ const pointerOnly = !enableArrowButtonTabStops
699
746
  const {
700
747
  enabled: tapCircleEnabled,
701
748
  size: tapCircleSize,
@@ -733,6 +780,9 @@ export const ScrollBar = ({
733
780
  // 負値が aria-valuemax へ露出して ARIA 違反 (valuemax < valuemin) になる。644 行の
734
781
  // getScrollBarMetrics と同一のクランプ規約)
735
782
  const maxScrollPosition = Math.max(contentSize - viewportSize, 0)
783
+ // ポインタ専用のバーは支援技術から隠れるので値の文字を持たない。スクロールできないバーは 0 %、中身が縮んで一時的に最大位置を
784
+ // 越えた位置は 100 %
785
+ const valueText = pointerOnly ? undefined : valueTextFormat.format(maxScrollPosition > 0 ? minmax(scrollPosition / maxScrollPosition, 0, 1) : 0)
736
786
  const effectiveTrackLength = Math.max(trackLength - thumbSize, 0)
737
787
  // scrollPosition が範囲外 (コンテンツ縮小直後の未クランプ 1 フレームや iOS バウンス値) でも
738
788
  // サムがトラック外へはみ出さないよう、レンダー時に [0, effectiveTrackLength] へクランプする。
@@ -1268,6 +1318,7 @@ export const ScrollBar = ({
1268
1318
  const { handleArrowPointerDown, handleArrowPointerUp, handleArrowKeyDown } = useArrowAutoRepeat({
1269
1319
  canUseArrowButtons,
1270
1320
  enableArrowButtons,
1321
+ keyboardOperable: !pointerOnly,
1271
1322
  resetTapScroll,
1272
1323
  scrollByStep,
1273
1324
  })
@@ -1484,13 +1535,14 @@ export const ScrollBar = ({
1484
1535
  }, [tapCircleOffsetX, tapCircleOffsetY, tapCircleSize])
1485
1536
 
1486
1537
  /**
1487
- * Renders one arrow button of the bar. It is a Tab stop (`tabIndex=0`) unless the host opted out
1488
- * with `enableArrowButtonTabStops={false}` (`tabIndex=-1`); either way it stays focusable from
1489
- * script, pointer-operable and named, and Enter / Space scrolls one step while it has focus.
1538
+ * Renders one arrow button of the bar: a named Tab stop (`tabIndex=0`) on which Enter / Space scrolls one step, or,
1539
+ * on a pointer-only bar (`enableArrowButtonTabStops={false}`), a pointer target out of the Tab order (`tabIndex=-1`)
1540
+ * inside the bar's `aria-hidden` subtree, whose presses never move focus. Pointer presses and press-and-hold repeat
1541
+ * work the same either way.
1490
1542
  *
1491
- * バーの矢印ボタン 1 個の描画。ホストが `enableArrowButtonTabStops={false}` で外さない限り Tab の
1492
- * 止まり先 (`tabIndex=0`)、外した場合は `tabIndex=-1`。いずれの場合もスクリプトからのフォーカス・
1493
- * ポインタ操作・アクセシブルネームは維持し、フォーカス中の Enter / Space で 1 ステップ移動。
1543
+ * バーの矢印ボタン 1 個の描画。名前付きの Tab の止まり先 (`tabIndex=0`) で Enter / Space により 1 ステップ移動するか、
1544
+ * ポインタ専用のバー (`enableArrowButtonTabStops={false}`) では Tab 順の外のポインタの的 (`tabIndex=-1`) で、
1545
+ * バーの `aria-hidden` の部分木の中にあり押下はフォーカスを動かさない。ポインタの押下と長押しの連続はどちらも同じ。
1494
1546
  *
1495
1547
  * @param direction - Step direction (-1 = toward the start, 1 = toward the end) / ステップ方向 (-1 = 始端側、1 = 終端側)
1496
1548
  * @param label - Accessible name / アクセシブルネーム
@@ -1502,7 +1554,7 @@ export const ScrollBar = ({
1502
1554
  <button
1503
1555
  key={key}
1504
1556
  type="button"
1505
- tabIndex={enableArrowButtonTabStops ? 0 : -1}
1557
+ tabIndex={pointerOnly ? -1 : 0}
1506
1558
  className="aqvs-scrollbar-arrow-button"
1507
1559
  style={{
1508
1560
  [mainSizeKey]: scrollBarWidth,
@@ -1552,11 +1604,17 @@ export const ScrollBar = ({
1552
1604
  // components-only アーティファクトには同梱されず、ホストのビルド内容任せになる
1553
1605
  data-visible={scrollBarVisible ? "true" : "false"}
1554
1606
  role="scrollbar"
1555
- tabIndex={-1}
1607
+ aria-label={names.bar}
1608
+ aria-hidden={pointerOnly ? true : undefined}
1609
+ // ポインタ専用のバーはフォーカスを受ける部品を持たない。押下の既定動作も取り消す: Chromium は無効の矢印の押下でも
1610
+ // pointerdown を届けたうえで、最も近いフォーカス可能な祖先へフォーカスを移す (取り消すとどこへも移さない)
1611
+ tabIndex={pointerOnly ? undefined : -1}
1612
+ onPointerDown={pointerOnly ? keepFocusOnPress : undefined}
1556
1613
  aria-controls={ariaControls}
1557
1614
  aria-valuenow={scrollPosition}
1558
1615
  aria-valuemin={0}
1559
1616
  aria-valuemax={maxScrollPosition}
1617
+ aria-valuetext={valueText}
1560
1618
  aria-orientation={horizontal ? "horizontal" : "vertical"}>
1561
1619
  {(!horizontal || enableHorizontalTapCircle) && scrollBarVisible && tapCircleEnabled && (
1562
1620
  <TapScrollCircle
@@ -1572,7 +1630,7 @@ export const ScrollBar = ({
1572
1630
  onDragChange={handleTapCircleDragChange}
1573
1631
  />
1574
1632
  )}
1575
- {renderArrowButton(-1, arrowLabels[0], arrowIcons[0], "arrow-start")}
1633
+ {renderArrowButton(-1, names.arrows[0], arrowIcons[0], "arrow-start")}
1576
1634
  <div
1577
1635
  key="track"
1578
1636
  className="aqvs-scrollbar-track"
@@ -1607,12 +1665,14 @@ export const ScrollBar = ({
1607
1665
  data-visible={scrollBarVisible || isDragging ? "true" : "false"}
1608
1666
  onPointerDown={handlePointerDownOnThumb}
1609
1667
  role="slider"
1668
+ aria-label={names.thumb}
1610
1669
  aria-orientation={horizontal ? "horizontal" : "vertical"}
1611
1670
  aria-valuenow={scrollPosition}
1612
1671
  aria-valuemin={0}
1613
1672
  aria-valuemax={maxScrollPosition}
1673
+ aria-valuetext={valueText}
1614
1674
  aria-disabled={!enableThumbDrag}
1615
- tabIndex={-1}>
1675
+ tabIndex={pointerOnly ? undefined : -1}>
1616
1676
  {/* スクロールバーのつまみ(可視部分) */}
1617
1677
  {/* biome-ignore lint/a11y/noStaticElementInteractions: スクロールバーのつまみは必要なインタラクションです */}
1618
1678
  <div
@@ -1639,7 +1699,7 @@ export const ScrollBar = ({
1639
1699
  />
1640
1700
  </div>
1641
1701
  </div>
1642
- {renderArrowButton(1, arrowLabels[1], arrowIcons[1], "arrow-end")}
1702
+ {renderArrowButton(1, names.arrows[1], arrowIcons[1], "arrow-end")}
1643
1703
  </div>
1644
1704
  )
1645
1705
  }
@@ -80,18 +80,16 @@ export type ScrollPaneProps = {
80
80
  /** Whether arrow buttons control the scroll position. / 矢印ボタンのスクロール操作を許可するかどうか。 */
81
81
  enableArrowButtons?: boolean
82
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"]`。
83
+ * Whether the pane's scrollbar is the keyboard user's scrolling control (default `true`) or a pointer-only control
84
+ * because the host scrolls this pane by keyboard itself (`false`), forwarded to the pane's ScrollBar. With `false`
85
+ * the bar carries `aria-hidden="true"`, has no Tab stop and no focusable part, and a press on it never moves focus;
86
+ * pointer scrolling is unchanged. The default stays `true` because otherwise the arrows are the pane's only scrolling
87
+ * control that Tab reaches. Full contract: `ScrollBarProps["enableArrowButtonTabStops"]`.
88
+ * ペインのスクロールバーがキーボード利用者のスクロール操作部品か (既定 `true`)、ホストがこのペインのキーボード
89
+ * スクロールを自分で持つためポインタ専用の部品か (`false`) の指定。ペインの ScrollBar へ転送。`false` ではバーが
90
+ * `aria-hidden="true"` を持ち、Tab の止まり先もフォーカス可能な部品も無く、押下はフォーカスを動かさない。ポインタの
91
+ * スクロールは変わらない。既定が `true` なのは、それ以外ではペインで Tab の届く唯一のスクロール操作部品だから。
92
+ * 契約の全文は `ScrollBarProps["enableArrowButtonTabStops"]`。
95
93
  */
96
94
  enableArrowButtonTabStops?: boolean
97
95
  /** Whether dragging the content area scrolls the pane. / コンテンツ領域のドラッグでスクロールさせるかどうか。 */
@@ -185,9 +183,9 @@ export type ScrollPaneProps = {
185
183
  */
186
184
  contentProps?: React.AriaAttributes & { id?: string; role?: React.AriaRole }
187
185
  /**
188
- * UI chrome locale forwarded to the pane's ScrollBar (arrow aria-labels; default `"en"`).
186
+ * UI chrome locale forwarded to the pane's ScrollBar (the names of the bar, its thumb and its arrows; default `"en"`).
189
187
  * An unsupported value throws a RangeError at render.
190
- * ペインの ScrollBar へ転送する UI クロームロケール (矢印の aria-label、既定 `"en"`)。
188
+ * ペインの ScrollBar へ転送する UI クロームロケール (バー・つまみ・矢印の名前、既定 `"en"`)。
191
189
  * 非対応値は描画時に RangeError。
192
190
  */
193
191
  locale?: VirtualScrollLocale
@@ -229,7 +229,7 @@ export type VirtualGridRange = {
229
229
  totalHeight: number
230
230
  }
231
231
 
232
- /** Live-region options: the consumer owns announcement wording/locale; the built-in catalog (`locale` / `labels`) covers only the seven chrome strings (arrows, pills, empty state) — row-parity. / liveRegion オプション: 読み上げの文言とロケールは消費側所有。内蔵カタログ (`locale` / `labels`) はクローム 7 文言 (矢印・ピル・空状態) のみ対象 — 行側と同一方針。 */
232
+ /** Live-region options: the consumer owns announcement wording/locale; the built-in catalog (`locale` / `labels`) covers only the eleven chrome strings (the scrollbar names, pills, empty state) — row-parity. / liveRegion オプション: 読み上げの文言とロケールは消費側所有。内蔵カタログ (`locale` / `labels`) はクローム 11 文言 (スクロールバーの名前・ピル・空状態) のみ対象 — 行側と同一方針。 */
233
233
  export type VirtualGridLiveRegionOptions = {
234
234
  /** Builds the announcement (same string = no re-announce, "" clears). / 読み上げ文言の組み立て (同一文字列 = 再読み上げなし、"" でクリア)。 */
235
235
  buildMessage: (range: VirtualGridRange) => string
@@ -419,11 +419,11 @@ export type VirtualGridProps<T> = {
419
419
  scrollBarOptions?: VirtualScrollScrollBarOptions
420
420
  liveRegion?: VirtualGridLiveRegionOptions
421
421
  /**
422
- * UI chrome locale (default `"en"`), forwarded to the embedded vertical VirtualScroll (its
423
- * arrows and the 0-row / all-frozen / degenerate-band empty state) and the horizontal bar
424
- * arrows. An unsupported value throws a RangeError at render.
425
- * UI クロームロケール (既定 `"en"`)。内蔵の縦 VirtualScroll (矢印と 0 行・全行固定・退化帯の
426
- * 空状態) と横バーの矢印へ転送。非対応値は描画時に RangeError。
422
+ * UI chrome locale (default `"en"`), forwarded to the embedded vertical VirtualScroll (its bar's
423
+ * names and the 0-row / all-frozen / degenerate-band empty state) and the horizontal bar (the
424
+ * names of the bar, its thumb and its arrows). An unsupported value throws a RangeError at render.
425
+ * UI クロームロケール (既定 `"en"`)。内蔵の縦 VirtualScroll (バーの名前と 0 行・全行固定・退化帯の
426
+ * 空状態) と横バー (バー・つまみ・矢印の名前) へ転送。非対応値は描画時に RangeError。
427
427
  */
428
428
  locale?: VirtualScrollLocale
429
429
  /**
@@ -605,9 +605,7 @@ const EMPTY_PLACED_COLUMNS: PlacedColumn[] = []
605
605
  const collectVisibleColumns = (startCol: number, endCol: number, getColWidth: (col: number) => number, colTree: ReturnType<typeof useFenwickMapTree>, maxColumns: number): { columns: PlacedColumn[]; widthUpdates: Array<{ index: number; value: number }>; truncated: number } => {
606
606
  const columns: PlacedColumn[] = []
607
607
  const widthUpdates: Array<{ index: number; value: number }> = []
608
- if (endCol < startCol) {
609
- return { columns, widthUpdates, truncated: 0 }
610
- }
608
+ // 呼び出し元は空窓 (start > end) を先に除くので、ここへ来る窓は 1 列以上
611
609
  const startPrefix = colTree.prefixSum(startCol, { materializeOption: { materialize: false } })
612
610
  // 窓先頭の絶対 left から幅を積む O(k) 走査 (列ごとの prefixSum O(k·log n) を回避 — 行 memo と同型)
613
611
  let runningLeft = startPrefix.cumulative - startPrefix.currentValue
@@ -850,8 +848,8 @@ const VirtualGridInner = <T,>(
850
848
  const colTree = useFenwickMapTree(colCount, validatedGetColWidth, colTreeOptions)
851
849
 
852
850
  // ---- 幅エポック (木の内容バージョン): updateColSize / self-heal 適用で繰上げ、窓キー不変でも
853
- // 配置 memo と列窓再計算を失効させる。行側は総高 (contentSize) を反応辺にするが、相殺
854
- // バッチリサイズ (総和不変) も拾えるようエポックはその厳密上位互換 ----
851
+ // 配置 memo と列窓再計算を失効させる。総幅は相殺バッチリサイズ (総和不変) で変わらないため
852
+ // 合図にならない (行側は VirtualScroll の木の版数が同じ役目を持つ) ----
855
853
  const [widthEpoch, setWidthEpoch] = useState(0)
856
854
 
857
855
  // ---- 凍結帯 (設計 §8) — 形状検証は fail-fast、colCount 超過だけは動的クランプ (シート
@@ -982,7 +980,7 @@ const VirtualGridInner = <T,>(
982
980
  /** Anchor the DOM currently shows (updated post-commit in the layout effect). / DOM が現在表示中のアンカー (commit 後の layout effect で更新)。 */
983
981
  const committedAnchorRef = useRef(0)
984
982
 
985
- const [totalWidth, setTotalWidth] = useState(() => colTree.getTotal() ?? 0)
983
+ const [totalWidth, setTotalWidth] = useState(() => colTree.getTotal())
986
984
  const totalWidthRef = useRef(totalWidth)
987
985
  totalWidthRef.current = totalWidth
988
986
  /** Mirrors the tree total into state+ref immediately (same tick — drag loops clamp fresh). / 木総幅を state + ref へ即時反映 (同 tick — ドラッグループが新鮮にクランプ)。 */
@@ -1715,11 +1713,14 @@ const VirtualGridInner = <T,>(
1715
1713
  // ---- liveRegion (2D — 文言は消費側所有、静定デバウンス) ----
1716
1714
  const [liveMessage, setLiveMessage] = useState("")
1717
1715
  const liveTimerRef = useRef<number | null>(null)
1716
+ // 埋め込みの一覧はアンマウントで待っていた範囲の通知を同期で配る (取りこぼさないため)。それはグリッド自身の後始末の後に
1717
+ // 届くので、アンマウントの後は読み上げを予約しない (予約すると消費側の buildMessage がアンマウントの後に走る)
1718
+ const isLiveRegionClosedRef = useRef(false)
1718
1719
  const liveRegionRef = useRef(liveRegion)
1719
1720
  liveRegionRef.current = liveRegion
1720
1721
  const scheduleLiveMessage = useCallback(() => {
1721
1722
  const options = liveRegionRef.current
1722
- if (options === undefined) {
1723
+ if (options === undefined || isLiveRegionClosedRef.current) {
1723
1724
  return
1724
1725
  }
1725
1726
  // 不正な debounceMs は黙って既定へ読み替えず読み上げを無効化する (警告は下の effect が 1 回だけ出す)
@@ -1734,10 +1735,8 @@ const VirtualGridInner = <T,>(
1734
1735
  liveTimerRef.current = null
1735
1736
  const range = buildRange()
1736
1737
  if (range !== null) {
1737
- setLiveMessage((prev) => {
1738
- const next = options.buildMessage(range)
1739
- return next === prev ? prev : next
1740
- })
1738
+ // 同じ文言の再設定は React が Object.is で捨てる (再描画しない) ので、比べずに書く (行側のライブリージョンと同じ)
1739
+ setLiveMessage(options.buildMessage(range))
1741
1740
  }
1742
1741
  }, debounce)
1743
1742
  }, [buildRange])
@@ -1750,14 +1749,16 @@ const VirtualGridInner = <T,>(
1750
1749
  Logger.warn(`[VirtualGrid] liveRegion.debounceMs must be a finite number >= 0, received ${liveDebounceMs}. Announcements are disabled.`)
1751
1750
  }
1752
1751
  }, [hasLiveRegion, liveDebounceValid, liveDebounceMs])
1753
- useEffect(
1754
- () => () => {
1752
+ useEffect(() => {
1753
+ // StrictMode の二重マウント (後始末の後に再び effect) でも読み上げを再開できるよう、本体で開き直す
1754
+ isLiveRegionClosedRef.current = false
1755
+ return () => {
1756
+ isLiveRegionClosedRef.current = true
1755
1757
  if (liveTimerRef.current !== null) {
1756
1758
  window.clearTimeout(liveTimerRef.current)
1757
1759
  }
1758
- },
1759
- [],
1760
- )
1760
+ }
1761
+ }, [])
1761
1762
  useEffect(() => {
1762
1763
  scheduleLiveMessage()
1763
1764
  }, [columnWindow, scheduleLiveMessage])
@@ -1849,9 +1850,10 @@ const VirtualGridInner = <T,>(
1849
1850
  if (found.cumulative === bandStart) {
1850
1851
  return { row: rowAnchor.index + effectiveFrozenRows, col: minmax(found.index + 1, 0, colCount - 1), offsetX: 0, offsetY: rowAnchor.offsetPx }
1851
1852
  }
1853
+ // 見つけた列の左端は木から読み直す (探索の結果の型は列が無い場合の undefined を含むため)
1854
+ const column = colTree.prefixSum(found.index, { materializeOption: { materialize: false } })
1852
1855
  // 行はシフト空間 → フル行空間へ +R 翻訳 (行凍結設計 §3 — 復元側は −R で往復恒等)
1853
- const colLeft = (found.cumulative ?? 0) - (found.currentValue ?? 0)
1854
- return { row: rowAnchor.index + effectiveFrozenRows, col: found.index, offsetX: Math.max(0, bandStart - colLeft), offsetY: rowAnchor.offsetPx }
1856
+ return { row: rowAnchor.index + effectiveFrozenRows, col: found.index, offsetX: Math.max(0, bandStart - (column.cumulative - column.currentValue)), offsetY: rowAnchor.offsetPx }
1855
1857
  },
1856
1858
  getViewportSize: () => ({ width: viewportRef.current.width, height: viewportRef.current.height }),
1857
1859
  // 縦はインセット込みの行側 getContentSize を単一情報源にする (range.totalHeight は木総高のみ)。
@@ -2003,7 +2005,7 @@ const VirtualGridInner = <T,>(
2003
2005
  onScroll={(request, previous) => {
2004
2006
  // バー操作は手動横入力 — 列アンカーの張りを解除する
2005
2007
  pendingColAnchorRef.current = null
2006
- return applyHxRef.current(typeof request === "function" ? request(previous ?? hxRef.current) : request)
2008
+ return applyHxRef.current(typeof request === "function" ? request(previous) : request)
2007
2009
  }}
2008
2010
  // バー固有の設定は縦バー (内包 VirtualScroll へ scrollBarOptions を全量透過) と同じ値を
2009
2011
  // 横バーへも渡す — 片方のバーにだけ効く設定面を作らない