@aiquants/virtualscroll 3.7.1 → 3.8.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.
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
1
+ import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"
2
2
  import { resolveVirtualScrollLabels, type VirtualScrollLabelOverrides, type VirtualScrollLocale } from "./labels.ts"
3
3
  import { Logger } from "./logger.ts"
4
4
  import { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneProps } from "./ScrollPane.tsx"
@@ -127,13 +127,49 @@ export type VirtualScrollHandle = {
127
127
  updateItemSize: (index: number, size: number) => void
128
128
  }
129
129
 
130
+ /**
131
+ * Scrollbar options of `VirtualScroll` (forwarded to its ScrollPane and bar) and of `VirtualGrid`
132
+ * (see `VirtualGridProps["scrollBarOptions"]` for which members reach which of the grid's two bars).
133
+ *
134
+ * `VirtualScroll` のスクロールバー設定 (ScrollPane とそのバーへ転送) であり、`VirtualGrid` の
135
+ * スクロールバー設定でもある (グリッドの 2 本のバーのどちらへ届くかは `VirtualGridProps["scrollBarOptions"]` 参照)。
136
+ */
130
137
  export type VirtualScrollScrollBarOptions = {
138
+ /** Scrollbar thickness in px (default 12). / スクロールバーの太さ (px、既定 12)。 */
131
139
  width?: number
140
+ /** Whether dragging the thumb scrolls (default `true`). / つまみのドラッグ操作を許可するかどうか (既定 `true`)。 */
132
141
  enableThumbDrag?: boolean
142
+ /** Whether pressing the track scrolls there (default `true`). / トラックの押下によるスクロールを許可するかどうか (既定 `true`)。 */
133
143
  enableTrackClick?: boolean
144
+ /** Whether the arrow buttons scroll (default `true`). / 矢印ボタンによるスクロールを許可するかどうか (既定 `true`)。 */
134
145
  enableArrowButtons?: boolean
146
+ /**
147
+ * Whether the scrollbar's arrow buttons are Tab stops (default `true`). Set it to `false` when
148
+ * the host already provides keyboard scrolling (roving row focus with Arrow / Page / Home / End):
149
+ * the arrows then only add two redundant Tab stops per bar — native scrollbars are never Tab
150
+ * stops either. `false` changes `tabIndex` alone (`-1`): the arrows stay pointer-operable and
151
+ * named, and Enter / Space still scroll when one is focused from script.
152
+ * The default stays `true` because the rows are not Tab stops, so without a host keyboard model
153
+ * the arrows are the only scrolling control a keyboard user can reach with Tab. Full contract:
154
+ * `ScrollBarProps["enableArrowButtonTabStops"]`.
155
+ * スクロールバーの矢印ボタンを Tab の止まり先にするかどうか (既定 `true`)。ホストがキーボード
156
+ * スクロールを既に提供する場合 (行のロービングフォーカスと矢印 / Page / Home / End) に `false` —
157
+ * 矢印はバー 1 本あたり冗長な Tab の止まり先を 2 つ足すだけになるため (ネイティブのスクロールバーも
158
+ * Tab の止まり先にならない)。`false` が変えるのは `tabIndex` (`-1`) だけで、ポインタ操作・
159
+ * アクセシブルネームと、スクリプトからフォーカスした矢印の Enter / Space は維持。既定が `true`
160
+ * なのは、行が Tab の止まり先ではないため、ホストのキーボードモデルが無ければ矢印がキーボード
161
+ * 利用者の Tab で届く唯一のスクロール操作部品だから。契約の全文は
162
+ * `ScrollBarProps["enableArrowButtonTabStops"]`。
163
+ */
164
+ enableArrowButtonTabStops?: boolean
165
+ /**
166
+ * Whether to show the auto-hiding Top / Bottom pills (default `false`; texts from `labels.scrollToTop` / `labels.scrollToBottom`).
167
+ * 自動で隠れる Top / Bottom ピルを表示するかどうか (既定 `false`。文言は `labels.scrollToTop` / `labels.scrollToBottom`)。
168
+ */
135
169
  enableScrollToTopBottomButtons?: boolean
170
+ /** Renderer for UI anchored near the thumb. / つまみ付近に重ねる UI のレンダラー。 */
136
171
  renderThumbOverlay?: ScrollPaneProps["renderThumbOverlay"]
172
+ /** Tap-scroll circle options (VirtualGrid: its single two-axis circle). / タップスクロールサークルの設定 (VirtualGrid では唯一の 2 軸サークル)。 */
137
173
  tapScrollCircleOptions?: ScrollPaneProps["tapScrollCircleOptions"]
138
174
  }
139
175
 
@@ -488,11 +524,11 @@ const toPanePositionWithInset = (logical: number, top: number) => (logical <= 0
488
524
  * Maximum run length of consecutive zero-height rows scanned linearly before attempting an
489
525
  * O(log n) jump to the next non-zero row via the Fenwick tree. Module-level export (NOT in the
490
526
  * package barrel) so VirtualGrid's column axis shares the SAME constant instead of duplicating
491
- * the value (the §5 no-copy rule — same treatment as ANCHOR_REBASE_DISTANCE).
527
+ * the value (a shared constant is never copied — same treatment as ANCHOR_REBASE_DISTANCE).
492
528
  *
493
529
  * 高さ 0 行の連続をこの件数まで線形走査し、超えたら Fenwick 木で次の非 0 行へ O(log n) ジャンプ
494
530
  * する閾値。モジュールレベル export (バレル非公開) — VirtualGrid 列軸が値の複製でなく同一定数を
495
- * 共有するため (§5 複製禁止規範 — ANCHOR_REBASE_DISTANCE と同じ扱い)。
531
+ * 共有するため (共有定数は複製しない — ANCHOR_REBASE_DISTANCE と同じ扱い)。
496
532
  */
497
533
  export const ZERO_HEIGHT_RUN_LIMIT = 1000
498
534
 
@@ -520,6 +556,216 @@ export const MAX_RENDERED_ITEMS = 2000
520
556
  */
521
557
  export const ANCHOR_REBASE_DISTANCE = 1_048_576 // 2^20 px — VirtualGrid の横アンカーが共有 import する (パッケージバレルへは非公開)
522
558
 
559
+ /**
560
+ * Snaps a CSS-px offset to the device-pixel grid of a window: the nearest offset whose device-px value is a
561
+ * whole number, `Math.round(cssPx × ratio) / ratio` (an exact half rounds toward +∞, as `Math.round` does). The items-wrapper translate goes through it: a composited layer moved by a fraction of a
562
+ * device pixel is resampled as a whole (2-px outlines, gaps and rings smear across neighbouring device rows and
563
+ * text blurs), while fractional layout positions inside the layer are painted on whole pixels already.
564
+ * Idempotent: a snapped value snaps to itself. A non-finite `cssPx` propagates (`NaN` in, `NaN` out).
565
+ * Module-level export (NOT in the package barrel).
566
+ *
567
+ * CSS px のオフセットをウィンドウの装置の画素の格子へ揃える処理。装置 px で整数になる最も近いオフセット
568
+ * `Math.round(cssPx × ratio) / ratio` (ちょうど半分は `Math.round` どおり +∞ 側)。
569
+ * 行ラッパーの平行移動はここを通す — 合成層を装置の画素の端数だけ動かすとブラウザは層全体を再標本化し
570
+ * (2px の輪郭・隙間・輪が隣の装置の行へ滲み、文字もぼける)、層の中の端数のレイアウト位置は描画が既に画素へ
571
+ * 揃えるため。冪等 (揃えた値はそのまま)。有限でない `cssPx` はそのまま伝わる (`NaN` は `NaN`)。
572
+ * モジュールレベル export (バレル非公開)。
573
+ *
574
+ * @param cssPx - Offset in CSS px; negative and fractional values included / CSS px のオフセット (負・小数を含む)
575
+ * @param ratio - Device px per CSS px of the window that paints the offset (its `devicePixelRatio`); a finite number > 0 / オフセットを描くウィンドウの CSS px あたりの装置 px (そのウィンドウの `devicePixelRatio`。0 より大きい有限数)
576
+ * @returns The snapped offset in CSS px / 揃えたオフセット (CSS px)
577
+ * @throws {RangeError} When `ratio` is not a finite number greater than 0 / `ratio` が 0 より大きい有限数でないとき
578
+ */
579
+ export const snapToDevicePixelGrid = (cssPx: number, ratio: number): number => {
580
+ if (!(Number.isFinite(ratio) && ratio > 0)) {
581
+ throw new RangeError(`[VirtualScroll] devicePixelRatio must be a finite number > 0, received ${ratio}`)
582
+ }
583
+ return Math.round(cssPx * ratio) / ratio
584
+ }
585
+
586
+ /**
587
+ * Returns the window of the document the element belongs to — the window whose screen paints it.
588
+ *
589
+ * 要素が属する文書のウィンドウ (要素を描く画面を持つウィンドウ) を返す処理。大域の `window` は使わない
590
+ * (別ウィンドウ・iframe へ描いた一覧の装置の画素比は、そのウィンドウのもの)。
591
+ *
592
+ * @param element - Element of the list / 一覧の要素
593
+ * @returns The window / ウィンドウ
594
+ * @throws {Error} When the element's document has no window (a document outside any browsing context, which paints nothing) / 要素の文書がウィンドウを持たないとき (閲覧の文脈の外の文書で、何も描かれない)
595
+ */
596
+ const windowOf = (element: Element): Window => {
597
+ const view = element.ownerDocument.defaultView
598
+ if (view === null) {
599
+ throw new Error("[VirtualScroll] the items wrapper belongs to a document without a window, so it has no device-pixel ratio")
600
+ }
601
+ return view
602
+ }
603
+
604
+ /**
605
+ * Unsubscribe that removes nothing: the subscription of a window without media queries, or of a wrapper with no
606
+ * painting window yet.
607
+ *
608
+ * 何も外さない購読解除 (メディアクエリを持たないウィンドウ、または描くウィンドウがまだ無いラッパーの購読)。
609
+ */
610
+ const unsubscribeNothing = (): void => {}
611
+
612
+ /**
613
+ * Server snapshot of the painting ratio for `useSyncExternalStore`. A server has no screen, so it is `null`
614
+ * and the wrapper keeps the exact translate; hydration renders with it too, so the hydrated markup matches the
615
+ * server HTML, and React replaces it with the snapped translate right after hydration.
616
+ *
617
+ * `useSyncExternalStore` に渡す、描く比のサーバーのスナップショット。サーバーに画面は無いので `null` で、
618
+ * ラッパーは厳密な平行移動のまま。ハイドレーションもこの値で描くのでサーバーの HTML と一致し、
619
+ * ハイドレーションの直後に React が揃えた平行移動へ置き換える。
620
+ *
621
+ * @returns Always `null` / 常に `null`
622
+ */
623
+ const readServerDevicePixelRatio = (): null => null
624
+
625
+ /**
626
+ * Returns the window of the JavaScript realm that runs React, or `null` outside a browser realm (server rendering).
627
+ * It is the expected painting window of a wrapper that has not attached yet: render cannot see the document it
628
+ * will be inserted into, and a host renders into its own window's document unless it deliberately renders into
629
+ * another window's document (an iframe or an opened window), which the attach detects (see `ItemsWrapper`).
630
+ *
631
+ * React を実行している JavaScript のレルムのウィンドウを返す処理 (ブラウザのレルムの外 = サーバー描画では
632
+ * `null`)。まだ取り付いていないラッパーの、描くと見込むウィンドウ。描画からは挿入先の文書が見えず、ホストは
633
+ * 別のウィンドウの文書 (iframe・開いたウィンドウ) へ意図して描くのでない限り自分のウィンドウの文書へ描くため。
634
+ * 見込みの確かめは取り付けが行う (`ItemsWrapper` を参照)。
635
+ *
636
+ * @returns The realm's window, or `null` without one / レルムのウィンドウ (無ければ `null`)
637
+ */
638
+ const readRealmWindow = (): Window | null => (typeof window === "undefined" ? null : window)
639
+
640
+ /**
641
+ * Subscribes to the device-pixel-ratio changes of a window (browser zoom, or the window moving to a screen of
642
+ * another density) through a `(resolution: <ratio>dppx)` media-query watch that is re-armed at each new ratio.
643
+ * A window without media queries (jsdom) cannot change its ratio, so nothing is armed there.
644
+ *
645
+ * ウィンドウの装置の画素比の変化 (ブラウザの拡大縮小・密度の違う画面へのウィンドウの移動) を、比が変わる
646
+ * たびに新しい比で張り直す `(resolution: <比>dppx)` のメディアクエリの監視で購読する処理。メディアクエリを
647
+ * 持たないウィンドウ (jsdom) は比が変わり得ないので何も張らない。
648
+ *
649
+ * @param view - Window whose ratio is watched / 比を監視するウィンドウ
650
+ * @param onChange - Called after each ratio change, once the watch is re-armed at the new ratio / 比が変わるたび、新しい比で監視を張り直した後に呼ぶ
651
+ * @returns Unsubscribe that removes the current watch / 今の監視を外す購読解除
652
+ */
653
+ const subscribeToDevicePixelRatio = (view: Window, onChange: () => void): (() => void) => {
654
+ if (typeof view.matchMedia !== "function") {
655
+ return unsubscribeNothing
656
+ }
657
+ let resolutionQuery = view.matchMedia(`(resolution: ${view.devicePixelRatio}dppx)`)
658
+ /**
659
+ * Re-arms the watch at the window's new ratio, then reports the change.
660
+ *
661
+ * ウィンドウの新しい比で監視を張り直してから変化を知らせる処理。
662
+ */
663
+ const handleResolutionChange = (): void => {
664
+ resolutionQuery.removeEventListener("change", handleResolutionChange)
665
+ // ❗ `(resolution: Ndppx)` が知らせるのは比 N との一致・不一致の切り替わりだけ。N 以外の比どうしの変化
666
+ // (もう一度の拡大縮小・さらに別の画面への移動) を捉えるには、変化のたびに新しい比で張り直すしかない
667
+ resolutionQuery = view.matchMedia(`(resolution: ${view.devicePixelRatio}dppx)`)
668
+ resolutionQuery.addEventListener("change", handleResolutionChange)
669
+ onChange()
670
+ }
671
+ resolutionQuery.addEventListener("change", handleResolutionChange)
672
+ /**
673
+ * Removes the current watch.
674
+ *
675
+ * 今の監視を外す処理。
676
+ */
677
+ const unsubscribe = (): void => {
678
+ resolutionQuery.removeEventListener("change", handleResolutionChange)
679
+ }
680
+ return unsubscribe
681
+ }
682
+
683
+ /**
684
+ * Props of `ItemsWrapper`.
685
+ *
686
+ * `ItemsWrapper` の props。
687
+ */
688
+ type ItemsWrapperProps = {
689
+ /** Exact translate in CSS px: top inset + render anchor - pane position / 厳密な平行移動 (CSS px。上のインセット + 描画アンカー - ペイン位置) */
690
+ readonly translateY: number
691
+ /** The rendered rows and the bottom inset / 描いた行と下のインセット */
692
+ readonly children: ReactNode
693
+ }
694
+
695
+ /**
696
+ * The items wrapper: a compositor layer (`will-change: transform`) moved by `translateY`, snapped to the
697
+ * device-pixel grid of the window that paints it (`snapToDevicePixelGrid`). Rule: the translate that reaches
698
+ * the screen is always snapped with the ratio of the window that paints the wrapper. Render reads the ratio
699
+ * through `useSyncExternalStore` from the expected painting window — the realm's window (`readRealmWindow`)
700
+ * until the wrapper has attached, since render cannot see the target document. The attach confirms it with the
701
+ * wrapper's own window (`windowOf`): when they agree (a list rendered into its own window's document), the first
702
+ * commit is already snapped and nothing is scheduled in the commit phase; when they differ (a list rendered into
703
+ * an iframe or an opened window), the attach switches to that window; the update is scheduled in the commit
704
+ * phase, so React re-renders the wrapper synchronously before the browser paints. Ratio changes re-render
705
+ * through the store subscription (`subscribeToDevicePixelRatio`), outside the commit phase. The server snapshot
706
+ * is `null`: server HTML and hydration carry the exact translate, replaced by the snapped one right after
707
+ * hydration.
708
+ *
709
+ * 行ラッパー。`translateY` で動かす合成層 (`will-change: transform`) で、平行移動は描くウィンドウの装置の画素の
710
+ * 格子へ揃える (`snapToDevicePixelGrid`)。規則は「画面へ届く平行移動は、常にラッパーを描くウィンドウの比で
711
+ * 揃っている」。描画は `useSyncExternalStore` で、描くと見込むウィンドウから比を読む — 描画からは挿入先の文書が
712
+ * 見えないので、ラッパーが取り付くまではレルムのウィンドウ (`readRealmWindow`)。取り付けがラッパー自身の
713
+ * ウィンドウ (`windowOf`) と突き合わせて確かめ、一致すれば (自分のウィンドウの文書に描いた一覧) 最初の確定から
714
+ * 揃っていて確定の段では何も予約しない。違えば (iframe・開いたウィンドウに描いた一覧) 取り付けがそのウィンドウへ
715
+ * 切り替え、この更新は確定の段で予約されるので、React はブラウザの paint の前にラッパーを同期で描き直す。
716
+ * 比の変化はストアの購読 (`subscribeToDevicePixelRatio`) で確定の段の外から描き直す。サーバーのスナップショットは
717
+ * `null` で、サーバーの HTML とハイドレーションは厳密な平行移動を持ち、ハイドレーションの直後に揃えた値へ置き換わる。
718
+ *
719
+ * @param props - The exact translate and the wrapped rows / 厳密な平行移動と包む行
720
+ * @returns The wrapper element / ラッパーの要素
721
+ * @throws {Error} When the wrapper attaches to a document without a window (see `windowOf`) / ウィンドウを持たない文書へ取り付いたとき (`windowOf` を参照)
722
+ * @throws {RangeError} When the painting window reports a ratio that is not a finite number > 0 (see `snapToDevicePixelGrid`) / 描くウィンドウの比が 0 より大きい有限数でないとき (`snapToDevicePixelGrid` を参照)
723
+ */
724
+ const ItemsWrapper = ({ translateY, children }: ItemsWrapperProps) => {
725
+ const [paintingWindow, setPaintingWindow] = useState<Window | null>(readRealmWindow)
726
+ /**
727
+ * Subscribes the store to the painting window's ratio changes.
728
+ *
729
+ * 描くウィンドウの比の変化へストアを購読させる処理。
730
+ */
731
+ const subscribe = useCallback((onChange: () => void): (() => void) => (paintingWindow === null ? unsubscribeNothing : subscribeToDevicePixelRatio(paintingWindow, onChange)), [paintingWindow])
732
+ /**
733
+ * Reads the painting window's current ratio (`null` while no window is known).
734
+ *
735
+ * 描くウィンドウの今の比を読む処理 (ウィンドウが分からない間は `null`)。
736
+ */
737
+ const readDevicePixelRatio = useCallback((): number | null => (paintingWindow === null ? null : paintingWindow.devicePixelRatio), [paintingWindow])
738
+ // 大域の devicePixelRatio と同名にしない — 宣言を消したとき参照が黙って大域のウィンドウの比へ化けるため
739
+ const paintingRatio = useSyncExternalStore(subscribe, readDevicePixelRatio, readServerDevicePixelRatio)
740
+ /**
741
+ * Ref callback that confirms the expected painting window against the window of the wrapper's document.
742
+ *
743
+ * 描くと見込んだウィンドウを、ラッパーの文書のウィンドウと突き合わせて確かめる ref コールバック。
744
+ */
745
+ const confirmPaintingWindow = useCallback(
746
+ (wrapper: HTMLDivElement | null): void => {
747
+ if (wrapper === null) {
748
+ return
749
+ }
750
+ const view = windowOf(wrapper)
751
+ // 同じウィンドウなら描画で読んだ比が正しく、確定の段の更新 (paint 前の同期の描き直し) は要らない
752
+ if (view !== paintingWindow) {
753
+ setPaintingWindow(view)
754
+ }
755
+ },
756
+ [paintingWindow],
757
+ )
758
+ // ❗ ラッパーは合成層なので、平行移動が装置の画素の端数を持つとブラウザは層ごと再標本化する (実測: -2440.5px で
759
+ // 2px の輪郭・2px の隙間・2px の輪が 3+1+3 行に滲み、下辺の隙間が消える)。揃えるのは層の平行移動だけでよい —
760
+ // 層の中の行の端数の位置は描画が画素へ揃える
761
+ const wrapperTranslateY = paintingRatio === null ? translateY : snapToDevicePixelGrid(translateY, paintingRatio)
762
+ return (
763
+ <div ref={confirmPaintingWindow} className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${wrapperTranslateY}px)`, willChange: "transform" }}>
764
+ {children}
765
+ </div>
766
+ )
767
+ }
768
+
523
769
  /**
524
770
  * Pixels emitted per horizontal arrow key press. Matches the ~40px browsers scroll for an arrow key.
525
771
  *
@@ -1047,7 +1293,7 @@ const VirtualScrollInner = <T,>(
1047
1293
  }: VirtualScrollProps<T>,
1048
1294
  ref: React.Ref<VirtualScrollHandle>,
1049
1295
  ) => {
1050
- const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
1296
+ const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableArrowButtonTabStops, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
1051
1297
  const resolvedLabels = useMemo(() => resolveVirtualScrollLabels(locale, labels), [locale, labels])
1052
1298
 
1053
1299
  const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, enableEscapeRowReturn = false, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
@@ -2426,6 +2672,18 @@ const VirtualScrollInner = <T,>(
2426
2672
  visibleStartIndex,
2427
2673
  ])
2428
2674
 
2675
+ /**
2676
+ * Renders the items wrapper for the pane's current scroll position (ScrollPane's `children` render prop).
2677
+ * The wrapper carries the scroll offset, the top inset and the render anchor as one translate, which
2678
+ * `ItemsWrapper` snaps to the device-pixel grid of the window that paints it; rows keep their exact tops.
2679
+ *
2680
+ * ペインの現在のスクロール位置に対する行ラッパーを描く処理 (ScrollPane の `children` の描画関数)。スクロール位置・
2681
+ * 上のインセット・描画アンカーを 1 つの平行移動としてラッパーが運び、`ItemsWrapper` がそれを描くウィンドウの
2682
+ * 装置の画素の格子へ揃える。行の上端は厳密値のまま。
2683
+ *
2684
+ * @param currentScrollPosition - The pane's scroll position in PANE coordinates / ペイン座標のスクロール位置
2685
+ * @returns The items wrapper, or the empty state when there are no items / 行ラッパー (項目が無ければ空状態)
2686
+ */
2429
2687
  const renderVisibleItems = useCallback(
2430
2688
  (currentScrollPosition: number) => {
2431
2689
  const shouldUseThrottledPosition = (callbackThrottleMs ?? 0) > 0
@@ -2480,10 +2738,10 @@ const VirtualScrollInner = <T,>(
2480
2738
  }))
2481
2739
 
2482
2740
  return (
2483
- <div className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${containerTop}px)`, willChange: "transform" }}>
2741
+ <ItemsWrapper translateY={containerTop}>
2484
2742
  {visibleItems}
2485
2743
  {bottomInset}
2486
- </div>
2744
+ </ItemsWrapper>
2487
2745
  )
2488
2746
  },
2489
2747
  [callbackThrottleMs, itemCount, fenwickTree, logicalScrollPosition, renderAnchor, renderingEndIndex, renderingStartIndex, resolvedInsets, scrollPosition, viewportSize, visibleItems],
@@ -2602,6 +2860,7 @@ const VirtualScrollInner = <T,>(
2602
2860
  enableThumbDrag={enableThumbDrag}
2603
2861
  enableTrackClick={enableTrackClick}
2604
2862
  enableArrowButtons={enableArrowButtons}
2863
+ enableArrowButtonTabStops={enableArrowButtonTabStops}
2605
2864
  enablePointerDrag={enablePointerDrag}
2606
2865
  pointerDragInputs={pointerDragInputs}
2607
2866
  renderThumbOverlay={renderThumbOverlay}
@@ -60,7 +60,7 @@ export type TapScrollVelocityInput = {
60
60
  * so never assert bit-equality (`toBe` / `Object.is`) of a pure-axis component against
61
61
  * `computeTapScrollSpeed` outside the committed exact fixtures. The rejected order
62
62
  * `s * (o / r)` would be bit-exact on pure axes (`o / r = ±1` exactly); the pin below
63
- * trades that for fixture-order independence (plan §4.2 T1 / §4.3).
63
+ * trades that for fixture-order independence (see the evaluation-order pin below).
64
64
  * - D2 (radial law under isotropy): when `s_x = s_y = s`, `|v| = s(r)` at every angle.
65
65
  * - D3 (separability): `v_a = s_a(r) * u_a(theta)` for a direction-only unit field `u`.
66
66
  * - D4 (isotropic direction fidelity — a CHOSEN axiom, not derived): when `s_x = s_y`,
@@ -69,8 +69,8 @@ export type TapScrollVelocityInput = {
69
69
  * D1-D3 alone do NOT force this law: they admit the whole family
70
70
  * `u = (cos phi(theta), sin phi(theta))` for any continuous strictly increasing angle remap
71
71
  * `phi` fixing the four cardinal angles (e.g. `phi(theta) = theta - epsilon * sin(4 theta)`).
72
- * D4 pins `phi = id`. That family is the registered invariant-preserving tuning point for
73
- * near-axis leakage (plan §4.4); v3.6.0 ships `phi = id`.
72
+ * D4 pins `phi = id`. That family is the invariant-preserving tuning point for near-axis
73
+ * leakage; the package ships `phi = id`.
74
74
  *
75
75
  * Evaluation-order pin: each component is computed literally as `s_a(r) * o_a / r`
76
76
  * (left-to-right, i.e. `(s * o) / r`). The two orders differ by 1 ulp for SOME inputs —
@@ -482,7 +482,7 @@
482
482
  transform: translateX(calc(var(--aqvs-grid-hx-residual, 0px) - var(--aqvs-grid-frozen-width, 0px)));
483
483
  }
484
484
 
485
- /* 末尾列クリップ (3.5.0 — ADR-19-3): left の max() は先頭優先の CSS エンコード。
485
+ /* 末尾列クリップ: left の max() は先頭優先の CSS エンコード。
486
486
  * ❗ この max() 形は trailingVisibleSize の calc() 版そのもの (クリップ左端 = extent − W_T_vis
487
487
  * が恒等) — CSS は JS ヘルパーを読めないための登記済み例外 (単一情報源レジスタのエントリ 1)。 */
488
488
  .aqvs-grid-row-trailing {
@@ -16,7 +16,7 @@ import { minmax } from "./utils.ts"
16
16
 
17
17
  /**
18
18
  * Default corner-relative offset (px) for BOTH grid tap-circle axes — the reachability arm (R)
19
- * of the three-arm placement law (plan §5). Derivation: full speed needs
19
+ * of the three-arm placement law. Derivation: full speed needs
20
20
  * `maxVisualDistance = 240 px` of advancing pull toward the far screen edge, which is a hard
21
21
  * stop on touch (`touch-action: none`). With `off = −200` the disc center sits
22
22
  * `sbw − off − size/2 = 12 + 200 − 20 = 192 px` inboard of each far root edge, giving a
@@ -26,11 +26,11 @@ import { minmax } from "./utils.ts"
26
26
  * nav-zone envelope ≥ ~2/3. Deliberately a LITERAL: it must NOT track a consumer-supplied
27
27
  * `maxVisualDistance` (coupling two knobs would make one silently move the other), and
28
28
  * shrinking `maxVisualDistance` instead is forbidden (it changes the speed law's `maxDistance`
29
- * input and breaks the T1 identity anchor).
29
+ * input, so a pure-axis drag would no longer reproduce the bar's own speed law).
30
30
  * グリッドタップサークル両軸のコーナー相対既定オフセット (px) — 3 アーム配置則の到達性アーム
31
31
  * (R)。導出: 全速には前進方向へ 240 px の引きが要り、タッチではスクリーン端がハードストップ。
32
32
  * −200 で中心は各遠端から 192 px 内側 = 最悪ケース引き分率 0.80 (ナビ帯込み 0.66)。意図的な
33
- * リテラル — 消費側 `maxVisualDistance` へ連動させない (ノブ連動の登記済み禁止)。
33
+ * リテラル — 消費側 `maxVisualDistance` へ連動させない (2 つのノブを連動させると一方が他方を黙って動かす)。
34
34
  */
35
35
  export const GRID_TAP_CIRCLE_DEFAULT_OFFSET = -200
36
36
 
@@ -51,27 +51,27 @@ export type UseGridTapScrollParams = {
51
51
  enabled: boolean
52
52
  /** Shared pull range (= `max(maxVisualDistance, 1)`). / 共有引き範囲 (= `max(maxVisualDistance, 1)`)。 */
53
53
  maxDistance: number
54
- /** Horizontal speed parameters (§4.1 Pₓ). / 横軸速度パラメータ (§4.1 Pₓ)。 */
54
+ /** Horizontal speed parameters (Pₓ, built from the scroll-band width and the column count). / 横軸速度パラメータ (Pₓ。スクロール帯幅と列数から構成)。 */
55
55
  xSpeedParams: TapScrollAxisSpeedParams
56
- /** Vertical speed parameters (§4.1 P_y). / 縦軸速度パラメータ (§4.1 P_y)。 */
56
+ /** Vertical speed parameters (P_y, built from the embedded pane height and the scroll-row count). / 縦軸速度パラメータ (P_y。埋め込みペイン高とスクロール行数から構成)。 */
57
57
  ySpeedParams: TapScrollAxisSpeedParams
58
58
  /** The grid's x apply seam (absolute position in, clamped applied position out). / グリッドの x 適用シーム (絶対位置 → クランプ後位置)。 */
59
59
  applyHxRef: { readonly current: (next: number) => number }
60
60
  /** Synchronous-fresh hx read. / hx の同期・最新読み。 */
61
61
  getHx: () => number
62
- /** The ONE extracted x clamp law (`getMaxHx` SSOT — §3.5-7). / 唯一抽出の x クランプ則 (`getMaxHx` SSOT — §3.5-7)。 */
62
+ /** The ONE extracted x clamp law (`getMaxHx` SSOT — never re-derived from the grid's tree). / 唯一抽出の x クランプ則 (`getMaxHx` SSOT — グリッドの木から再導出しない)。 */
63
63
  getMaxHx: () => number
64
- /** y apply seam: wraps `scrollBy(delta)` AND syncs `vyRef.current = applied` before returning (§3.5-8 — NOT a bare handle passthrough). / y 適用シーム: `scrollBy(delta)` をラップし `vyRef.current = applied` を同期してから返す (§3.5-8 — 素のハンドル素通しではない)。 */
64
+ /** y apply seam: wraps `scrollBy(delta)` AND syncs `vyRef.current = applied` before returning (NOT a bare handle passthrough — without the sync, x-frame notifications during a diagonal drag carry a stale y). / y 適用シーム: `scrollBy(delta)` をラップし `vyRef.current = applied` を同期してから返す (素のハンドル素通しではない — 同期が無いと対角ドラッグ中の x フレーム通知が stale な y を運ぶ)。 */
65
65
  applyVy: (delta: number) => number
66
66
  /** Synchronous-fresh vy read — MUST read the same authority `applyVy` resolves against (the embedded pane's position), never a notification mirror with a second writer (v3.6.1 — a lagging mirror base overstates `actualDelta` and drives the residual negative = yo-yo). / vy の同期・最新読み — `applyVy` が解決するのと同一権威 (埋め込みペイン位置) を読むこと。第 2 の書き手を持つ通知鏡像は禁止 (v3.6.1 — 遅延鏡像基準は `actualDelta` を過大化し残差を負へ落とすヨーヨー)。 */
67
67
  getVy: () => number
68
68
  /** y extent read through the embedded handle's freshness channel (never re-derived). / 埋め込みハンドルの鮮度チャネル経由の y 延長 (再導出禁止)。 */
69
69
  getMaxVy: () => number
70
- /** Pending column anchor — cleared ONLY on frames whose APPLIED hx delta ≠ 0 (§3.5-5). / 保留列アンカー — 適用済み hx デルタ ≠ 0 のフレームのみ解除 (§3.5-5)。 */
70
+ /** Pending column anchor — cleared ONLY on frames whose APPLIED hx delta ≠ 0 (a request the clamp swallows keeps it). / 保留列アンカー — 適用済み hx デルタ ≠ 0 のフレームのみ解除 (クランプに吸収された要求では残す)。 */
71
71
  pendingColAnchorRef: { current: unknown }
72
- /** x extent-growth freshness key (columnWindow / total width — §3.5-3). / x 延長成長の鮮度キー (columnWindow / 総幅 — §3.5-3)。 */
72
+ /** x extent-growth freshness key (columnWindow / total width — a change re-arms a loop parked at the edge). / x 延長成長の鮮度キー (columnWindow / 総幅 — 変化で端に止まったループを再武装)。 */
73
73
  xExtentFreshness: unknown
74
- /** y extent-growth freshness key (the vertical range state — §3.5-3). / y 延長成長の鮮度キー (縦レンジ state — §3.5-3)。 */
74
+ /** y extent-growth freshness key (the vertical range state — a change re-arms a loop parked at the edge). / y 延長成長の鮮度キー (縦レンジ state — 変化で端に止まったループを再武装)。 */
75
75
  yExtentFreshness: unknown
76
76
  }
77
77
 
@@ -89,7 +89,7 @@ export type UseGridTapScrollResult = {
89
89
  * Owns the unified tap circle's drag state and the two-axis rAF integration loop.
90
90
  * 統合タップサークルのドラッグ状態と 2 軸 rAF 積分ループを所有するフック。
91
91
  *
92
- * Per-frame semantics (plan §8.3 — implemented verbatim):
92
+ * Per-frame semantics:
93
93
  *
94
94
  * ```text
95
95
  * dt = min(max((t − last)/1000, 0), TAP_SCROLL_MAX_FRAME_DELTA_SECONDS); skip if dt ≤ 0
@@ -186,7 +186,7 @@ export const useGridTapScroll = (params: UseGridTapScrollParams): UseGridTapScro
186
186
  */
187
187
  const step = useCallback(
188
188
  (timestamp: number) => {
189
- // この rAF ループ意味論は ScrollBar.tsx の tap ループ (stepAutoScroll) と双子 — 片方を直したらもう片方も直すこと (ADR-27)
189
+ // この rAF ループ意味論は ScrollBar.tsx の tap ループ (stepAutoScroll) と双子 — 片方を直したらもう片方も直すこと
190
190
  const current = paramsRef.current
191
191
  const state = tapDragStateRef.current
192
192
  if (!state.active || state.direction === 0) {