@aiquants/virtualscroll 3.9.2 → 3.10.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.
@@ -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
  /**
@@ -4,7 +4,7 @@ import { resolveVirtualScrollLabels, type VirtualScrollLabelOverrides, type Virt
4
4
  import { Logger } from "./logger.ts"
5
5
  import { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneProps } from "./ScrollPane.tsx"
6
6
  import { useFenwickMapTree } from "./useFenwickMapTree.ts"
7
- import { minmax } from "./utils.ts"
7
+ import { keepFocusOnPress, minmax } from "./utils.ts"
8
8
 
9
9
  /**
10
10
  * Represents the current state of the rendered range and scroll metrics.
@@ -196,21 +196,23 @@ export type VirtualScrollScrollBarOptions = {
196
196
  /** Whether the arrow buttons scroll (default `true`). / 矢印ボタンによるスクロールを許可するかどうか (既定 `true`)。 */
197
197
  enableArrowButtons?: boolean
198
198
  /**
199
- * Whether the scrollbar's arrow buttons are Tab stops (default `true`). Set it to `false` when
200
- * the host already provides keyboard scrolling (roving row focus with Arrow / Page / Home / End):
201
- * the arrows then only add two redundant Tab stops per bar — native scrollbars are never Tab
202
- * stops either. `false` changes `tabIndex` alone (`-1`): the arrows stay pointer-operable and
203
- * named, and Enter / Space still scroll when one is focused from script.
204
- * The default stays `true` because the rows are not Tab stops, so without a host keyboard model
205
- * the arrows are the only scrolling control a keyboard user can reach with Tab. Full contract:
199
+ * Whether the scroll chrome is the keyboard user's scrolling control (default `true`), or pointer-only because the
200
+ * host scrolls the list by keyboard itself (`false`: roving row focus with Arrow / Page / Home / End).
201
+ * `true`: the scrollbar is a named `role="scrollbar"` with a named `role="slider"` thumb, and its arrows (and the
202
+ * visible scroll-to-edge pills) are Tab stops — the rows are not Tab stops, so without a host keyboard model they are
203
+ * the only scrolling controls a keyboard user can reach with Tab.
204
+ * `false`: the scrollbar and the scroll-to-edge pills become pointer-only, like a native scrollbar: both carry
205
+ * `aria-hidden="true"`, nothing in them is a Tab stop, and a press on them never moves focus (neither onto them nor
206
+ * away from where the host put it); pointer scrolling and the pills' clicks are unchanged. Full contract:
206
207
  * `ScrollBarProps["enableArrowButtonTabStops"]`.
207
- * スクロールバーの矢印ボタンを Tab の止まり先にするかどうか (既定 `true`)。ホストがキーボード
208
- * スクロールを既に提供する場合 (行のロービングフォーカスと矢印 / Page / Home / End) に `false` —
209
- * 矢印はバー 1 本あたり冗長な Tab の止まり先を 2 つ足すだけになるため (ネイティブのスクロールバーも
210
- * Tab の止まり先にならない)。`false` が変えるのは `tabIndex` (`-1`) だけで、ポインタ操作・
211
- * アクセシブルネームと、スクリプトからフォーカスした矢印の Enter / Space は維持。既定が `true`
212
- * なのは、行が Tab の止まり先ではないため、ホストのキーボードモデルが無ければ矢印がキーボード
213
- * 利用者の Tab で届く唯一のスクロール操作部品だから。契約の全文は
208
+ * スクロールの部品がキーボード利用者のスクロール操作部品か (既定 `true`)、ホストが一覧のキーボードスクロールを
209
+ * 自分で持つためポインタ専用か (`false`: 行のロービングフォーカスと矢印 / Page / Home / End) の指定。
210
+ * `true`: スクロールバーは名前付きの `role="scrollbar"` で名前付きの `role="slider"` のつまみを持ち、矢印 (と見えている
211
+ * 端へ戻るピル) は Tab の止まり先 — 行は Tab の止まり先ではないため、ホストのキーボードモデルが無ければ Tab で届く
212
+ * 唯一のスクロール操作部品。
213
+ * `false`: スクロールバーと端へ戻るピルはネイティブのスクロールバーと同じくポインタ専用になる。どちらも
214
+ * `aria-hidden="true"` を持ち、中に Tab の止まり先は無く、押下はフォーカスを動かさない (部品へも、ホストが置いた
215
+ * 場所の外へも)。ポインタのスクロールとピルの click は変わらない。契約の全文は
214
216
  * `ScrollBarProps["enableArrowButtonTabStops"]`。
215
217
  */
216
218
  enableArrowButtonTabStops?: boolean
@@ -314,12 +316,12 @@ export type VirtualScrollLiveRegionOptions = {
314
316
  * Formats the announcement text. Called after the visible range settles; returning the same
315
317
  * string as last time leaves the DOM untouched (no re-announcement), returning `""` clears
316
318
  * the region. The live-region wording and its language are consumer-owned: the package's
317
- * built-in catalog (`locale` / `labels`) covers only the seven chrome strings (scrollbar arrow
318
- * aria-labels, the scroll-to-edge pills and the empty state), never announcements.
319
+ * built-in catalog (`locale` / `labels`) covers only the eleven chrome strings (the scrollbar's
320
+ * accessible names, the scroll-to-edge pills and the empty state), never announcements.
319
321
  * 読み上げ文言を組み立てる。可視範囲が静定した後に呼ばれ、前回と同じ文字列なら DOM を
320
322
  * 触らない (再読み上げしない)。`""` を返すとリージョンを空にする。読み上げの文面と言語は
321
- * 消費側の所有物 — パッケージの内蔵カタログ (`locale` / `labels`) が扱うのはクローム 7 文言
322
- * (スクロールバー矢印の aria-label、端スクロールピル、空状態) だけで、読み上げは対象外。
323
+ * 消費側の所有物 — パッケージの内蔵カタログ (`locale` / `labels`) が扱うのはクローム 11 文言
324
+ * (スクロールバーのアクセシブルネーム、端スクロールピル、空状態) だけで、読み上げは対象外。
323
325
  *
324
326
  * @param range - The settled visible range / 静定した可視範囲
325
327
  * @returns Announcement text / 読み上げ文言
@@ -428,11 +430,11 @@ export type VirtualScrollProps<T> = {
428
430
  */
429
431
  liveRegion?: VirtualScrollLiveRegionOptions
430
432
  /**
431
- * UI chrome locale of the built-in strings (default `"en"`): the scrollbar arrow aria-labels,
432
- * the scroll-to-edge pills and the empty-state text. Live-region wording is not affected (see
433
+ * UI chrome locale of the built-in strings (default `"en"`): the scrollbar's accessible names
434
+ * (the bar, its thumb and its arrows), the scroll-to-edge pills and the empty-state text. Live-region wording is not affected (see
433
435
  * {@link VirtualScrollLiveRegionOptions.format}). An unsupported value throws a RangeError at
434
436
  * render; no language negotiation happens.
435
- * 内蔵文言 (スクロールバー矢印の aria-label、端スクロールピル、空状態文言) の UI クロームロケール
437
+ * 内蔵文言 (スクロールバーのアクセシブルネーム — バー・つまみ・矢印 —、端スクロールピル、空状態文言) の UI クロームロケール
436
438
  * (既定 `"en"`)。ライブリージョンの文言には影響しない ({@link VirtualScrollLiveRegionOptions.format}
437
439
  * 参照)。非対応値は描画時に RangeError、言語ネゴシエーションなし。
438
440
  */
@@ -765,14 +767,25 @@ type ItemsWrapperProps = {
765
767
  * render anchor is a whole number of device px — for example when every row height is a multiple of a lattice L with
766
768
  * L × ratio a whole number (L = 4 px at ratios in quarter steps).
767
769
  *
770
+ * The wrapper sits in `.aqvs-items-boundary`, its containing block and a relayout boundary: a positioned box with size and
771
+ * layout containment that is neither a flex nor a grid item, attached to the top, left and right padding edges of the pane
772
+ * content (so the wrapper keeps its position and width) with a height of 0. A step that mounts or unmounts rows therefore
773
+ * lays out from that box instead of from the document root. The rows overflow it unclipped, as ink overflow that is not
774
+ * part of the pane content's scrollable area, and the box takes no hit test of its own.
775
+ *
768
776
  * 行ラッパー。`translateY` で動かす合成層 (`will-change: transform`) で、平行移動は描くウィンドウの装置の画素の格子へ
769
777
  * `snapEdge` の側で揃える (`snapToDevicePixelGrid`。比は `usePaintingDevicePixelRatio` が描画の中で読むので、自分の
770
778
  * ウィンドウの文書に描いた一覧は最初の確定から揃う)。揃えるのは層の平行移動だけ。層の中の行は厳密なレイアウトの位置に
771
779
  * 描かれ、角の丸い枠線・輪・輪郭はそこで滲む。行が装置の画素の整数から始まるのは、描画のアンカーからの行の位置が装置 px の
772
780
  * 整数のときだけ (例: どの行の高さも、L × 比が整数になる格子 L の倍数のとき。比が 4 分の 1 刻みなら L = 4px)。
773
781
  *
782
+ * ラッパーは包含ブロックで配置の境界の `.aqvs-items-boundary` の中に置く。位置指定され、大きさと配置を封じ込め、flex の子でも
783
+ * grid の子でもない箱で、ペインの中身のパディングの上・左・右の辺に付き (ラッパーの位置と幅は変わらない)、高さは 0。行を足し引き
784
+ * する 1 段の配置は、文書の根ではなくこの箱から始まる。行は箱から切り取られずにはみ出し (ペインの中身のスクロールできる範囲に
785
+ * 数えないインクのはみ出し)、箱自身は当たり判定を取らない。
786
+ *
774
787
  * @param props - The exact translate, the edge it keeps and the wrapped rows / 厳密な平行移動・守る端・包む行
775
- * @returns The wrapper element / ラッパーの要素
788
+ * @returns The boundary box holding the wrapper element / ラッパーの要素を包む境界の箱
776
789
  * @throws {Error} When the wrapper attaches to a document without a window (see `usePaintingDevicePixelRatio`) / ウィンドウを持たない文書へ取り付いたとき (`usePaintingDevicePixelRatio` を参照)
777
790
  * @throws {RangeError} When the painting window reports a ratio that is not a finite number > 0 (see `snapToDevicePixelGrid`) / 描くウィンドウの比が 0 より大きい有限数でないとき (`snapToDevicePixelGrid` を参照)
778
791
  */
@@ -785,9 +798,13 @@ const ItemsWrapper = ({ translateY, snapEdge, children }: ItemsWrapperProps) =>
785
798
  // ❗ 層の様式は will-change: transform だけ。perspective や 3D の変換を足すと Chromium は描画の切り取り (cull rect) を
786
799
  // 外して描くが、行を非 2D の変換の下で合成するので画素比によっては層ごと再標本化する (実測: 比 1.25 の明色で選択の輪と
787
800
  // フォーカスの輪の間の帯が混ざる)。窓をずらす 1 段がはみ出しを切る中身の行を描き直す費用は、その代わりに受け入れる
801
+ // ❗ ペインの中身は flex の子で配置の境界になれない。境界の箱を挟まないと、行を足し引きする 1 段の配置が毎回文書の根から
802
+ // 始まる (実測: 4 倍の CPU で 1 段の配置の時間のすべてが文書の根から)
788
803
  return (
789
- <div ref={attach} className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${wrapperTranslateY}px)`, willChange: "transform" }}>
790
- {children}
804
+ <div className="aqvs-items-boundary">
805
+ <div ref={attach} className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${wrapperTranslateY}px)`, willChange: "transform" }}>
806
+ {children}
807
+ </div>
791
808
  </div>
792
809
  )
793
810
  }
@@ -1000,9 +1017,72 @@ const computeRenderingRangesHuge = (effectiveScrollPosition: number, viewportSiz
1000
1017
  * 高精度タイムスタンプを可能なら取得。
1001
1018
  */
1002
1019
  /**
1003
- * Calculates rendering boundaries from current scroll metrics.
1020
+ * The first visible row at a logical scroll position (`resolveVisibleStartRow`).
1021
+ *
1022
+ * 論理スクロール位置での先頭の可視行 (`resolveVisibleStartRow`)。
1023
+ */
1024
+ export type VisibleStartRow = {
1025
+ /** Index of the first visible row / 先頭の可視行の index */
1026
+ readonly index: number
1027
+ /** Logical top of that row (px); `position - top` is the part hidden above the viewport top / その行の論理上端 (px)。`position - top` がビューポートの上端より上に隠れた量 */
1028
+ readonly top: number
1029
+ }
1030
+
1031
+ /**
1032
+ * The part of the Fenwick tree `resolveVisibleStartRow` reads.
1033
+ *
1034
+ * `resolveVisibleStartRow` が読む Fenwick 木の部分。
1035
+ */
1036
+ type VisibleStartRowTree = Pick<ReturnType<typeof useFenwickMapTree>, "findIndexAtOrAfter" | "prefixSum">
1037
+
1038
+ /** Tree reads that only look the rows up, never materialising them / 行を引くだけで具現化しない木の読み方 */
1039
+ const LOOKUP_ONLY = { materializeOption: { materialize: false } } as const
1040
+
1041
+ /**
1042
+ * Resolves the first visible row at a logical scroll position by the visible-start boundary rule — the one rule that
1043
+ * `scrollTo` pins, `getScrollAnchor` reports, `updateItemSize` compensates above and `computeRenderingRanges` renders from:
1044
+ *
1045
+ * - the row whose span contains the position: `top <= position < top + height`;
1046
+ * - a row whose bottom equals the position lies entirely above the viewport, so the next row starts exactly there and is the
1047
+ * first visible row, at offset 0;
1048
+ * - at the end, the last row stays the first visible row (the end clamp): when the position equals its bottom, and when the
1049
+ * position lies past the end of the content (a transient after the list shrinks, before the pane clamps).
1050
+ *
1051
+ * Module-level export (NOT in the package barrel).
1052
+ *
1053
+ * 先頭の可視行を、可視の先頭の境界の規則で論理スクロール位置から解決する処理。`scrollTo` が留め、`getScrollAnchor` が知らせ、
1054
+ * `updateItemSize` がその上の変化を補正し、`computeRenderingRanges` が描き始める、ただ 1 つの規則。
1055
+ *
1056
+ * - 位置を含む行 (`上端 <= 位置 < 上端 + 高さ`)。
1057
+ * - 下端が位置に一致する行は丸ごとビューポートの上にあるので、ちょうどそこから始まる次の行がオフセット 0 で先頭の可視行。
1058
+ * - 末尾では最後の行が先頭の可視行のまま (末尾のクランプ)。位置がその下端に一致するときと、位置が中身の末尾を越えるとき
1059
+ * (一覧が縮んでからペインがクランプするまでの過渡状態)。
1060
+ *
1061
+ * モジュールレベル export (バレル非公開)。
1004
1062
  *
1005
- * 現在のスクロール情報から描画範囲を算出。
1063
+ * @param fenwickTree - The row-height tree / 行の高さの木
1064
+ * @param position - Logical scroll position (px, 0 or more) / 論理スクロール位置 (px。0 以上)
1065
+ * @param itemCount - Number of rows, 1 or more / 行の数 (1 以上)
1066
+ * @returns The first visible row and its logical top / 先頭の可視行とその論理上端
1067
+ */
1068
+ export const resolveVisibleStartRow = (fenwickTree: VisibleStartRowTree, position: number, itemCount: number): VisibleStartRow => {
1069
+ const lastIndex = itemCount - 1
1070
+ const found = fenwickTree.findIndexAtOrAfter(position, LOOKUP_ONLY)
1071
+ if (found.index === -1 || found.index > lastIndex || found.cumulative === undefined || found.currentValue === undefined) {
1072
+ const last = fenwickTree.prefixSum(lastIndex, LOOKUP_ONLY)
1073
+ return { index: lastIndex, top: last.cumulative - last.currentValue }
1074
+ }
1075
+ if (found.cumulative === position && found.index < lastIndex) {
1076
+ return { index: found.index + 1, top: found.cumulative }
1077
+ }
1078
+ return { index: found.index, top: found.cumulative - found.currentValue }
1079
+ }
1080
+
1081
+ /**
1082
+ * Calculates rendering boundaries from current scroll metrics. The visible rows start at the row `resolveVisibleStartRow`
1083
+ * resolves.
1084
+ *
1085
+ * 現在のスクロール情報から描画範囲を算出。可視の行は `resolveVisibleStartRow` が解決する行から始まる。
1006
1086
  */
1007
1087
  export const computeRenderingRanges = (scrollPosition: number, viewportSize: number, overscanCount: number, itemSize: number, getItemHeight: (index: number) => number, fenwickTree: ReturnType<typeof useFenwickMapTree>, totalHeight: number) => {
1008
1088
  if (itemSize === 0) {
@@ -1013,31 +1093,9 @@ export const computeRenderingRanges = (scrollPosition: number, viewportSize: num
1013
1093
  if (itemSize >= Number.MAX_SAFE_INTEGER) {
1014
1094
  return computeRenderingRangesHuge(effectiveScrollPosition, viewportSize, overscanCount, itemSize, getItemHeight, fenwickTree, totalHeight, hasFiniteTotal)
1015
1095
  }
1016
- const { index: rawStartIndex, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(effectiveScrollPosition, { materializeOption: { materialize: false } })
1017
- const startIndex =
1018
- rawStartIndex === -1
1019
- ? (() => {
1020
- // Fenwick がインデックスを返さない場合は末尾に合わせて開始位置を再計算
1021
- if (viewportSize <= 0) {
1022
- return itemSize - 1
1023
- }
1024
- return (cumulative ?? 0) < effectiveScrollPosition + (currentValue ?? 0) ? itemSize - 1 : 0
1025
- })()
1026
- : rawStartIndex
1027
- let visibleStartIndex = sanitizeIndex(startIndex, itemSize)
1028
-
1029
- let visibleHeight = 0
1030
- if (rawStartIndex !== -1 && cumulative === effectiveScrollPosition) {
1031
- visibleStartIndex = sanitizeIndex(rawStartIndex + 1, itemSize)
1032
- visibleHeight = 0
1033
- } else if (visibleStartIndex === rawStartIndex && cumulative !== undefined && currentValue !== undefined) {
1034
- const itemTop = cumulative - currentValue
1035
- visibleHeight = itemTop - effectiveScrollPosition
1036
- } else {
1037
- const { cumulative: startCumulative, currentValue: startHeight } = fenwickTree.prefixSum(visibleStartIndex, { materializeOption: { materialize: false } })
1038
- const itemTop = (startCumulative ?? 0) - (startHeight ?? 0)
1039
- visibleHeight = itemTop - effectiveScrollPosition
1040
- }
1096
+ const startRow = resolveVisibleStartRow(fenwickTree, effectiveScrollPosition, itemSize)
1097
+ let visibleStartIndex = startRow.index
1098
+ let visibleHeight = startRow.top - effectiveScrollPosition
1041
1099
 
1042
1100
  const initialOffset = visibleHeight
1043
1101
 
@@ -1321,6 +1379,8 @@ const VirtualScrollInner = <T,>(
1321
1379
  ref: React.Ref<VirtualScrollHandle>,
1322
1380
  ) => {
1323
1381
  const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableArrowButtonTabStops, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
1382
+ // 合図はスクロールバーと同じ 1 つ (既定 true)。端へ戻るピルもバーと同じくポインタ専用になる
1383
+ const scrollChromeIsPointerOnly = enableArrowButtonTabStops === false
1324
1384
  const resolvedLabels = useMemo(() => resolveVirtualScrollLabels(locale, labels), [locale, labels])
1325
1385
 
1326
1386
  const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, enableEscapeRowReturn = false, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
@@ -1871,9 +1931,9 @@ const VirtualScrollInner = <T,>(
1871
1931
  const didApplyInitialOffsetRef = useRef(false)
1872
1932
 
1873
1933
  /**
1874
- * Flushes queued onScroll notifications.
1934
+ * Applies `initialScrollOffset` once, as the highest-priority initial position, synchronising the pane and the logical position.
1875
1935
  *
1876
- * キューされた onScroll 通知を実行。
1936
+ * `initialScrollOffset` を最優先の初期位置として一度だけ適用し、ペインと論理位置を同期させる effect。
1877
1937
  */
1878
1938
  useEffect(() => {
1879
1939
  if (didApplyInitialOffsetRef.current) return
@@ -2006,13 +2066,15 @@ const VirtualScrollInner = <T,>(
2006
2066
  * Updates the size of a specific item. Contract: after this call, `getItemHeight(index)` must
2007
2067
  * return the same `size`; `getItemHeight` is the source of truth, so rows inside the current
2008
2068
  * rendering window (including overscan) are otherwise reverted to the `getItemHeight` value by
2009
- * height reconciliation on the next render. A change above the first visible row moves the scroll
2069
+ * height reconciliation on the next render. A change above the first visible row (the row of the pending
2070
+ * alignment while one is pending, otherwise the row `resolveVisibleStartRow` resolves) moves the scroll
2010
2071
  * position by the same delta (layout-shift compensation), carries the remembered alignment along and
2011
2072
  * is reported through `onScrollAdjust` (`"item-resize"`) before this returns.
2012
2073
  *
2013
2074
  * 指定されたアイテムのサイズを更新。契約: 呼び出し後は `getItemHeight(index)` も同じ値を返すこと。
2014
2075
  * `getItemHeight` が正であるため、そうでない場合は描画ウィンドウ (オーバースキャン含む) 内の行が
2015
- * 次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。先頭の可視行より上の変化はスクロール位置を
2076
+ * 次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。先頭の可視行 (保留中の揃えがあればその行、
2077
+ * 無ければ `resolveVisibleStartRow` が解決する行) より上の変化はスクロール位置を
2016
2078
  * 同じ差だけ動かし (レイアウトシフトの補正)、覚えた揃えも一緒に動かして、戻る前に `onScrollAdjust`
2017
2079
  * (`"item-resize"`) で知らせる。
2018
2080
  *
@@ -2045,18 +2107,12 @@ const VirtualScrollInner = <T,>(
2045
2107
  const currentScrollTop = latestScrollPositionRef.current
2046
2108
  const insetsTop = normalizeInsets(contentInsets).top
2047
2109
  const logicalScrollTop = toLogicalPositionWithInset(currentScrollTop, insetsTop)
2048
- // Use materialize: false for performance, we just need the index
2049
- const { index, cumulative } = fenwickTree.findIndexAtOrAfter(logicalScrollTop, { materializeOption: { materialize: false } })
2050
- // findIndexAtOrAfter は「下端(cumulative) >= scrollTop の最小 index」を返すため、
2051
- // アイテム index の下端がちょうど scrollTop に一致する場合、その行は完全にビューポート上方にある。
2052
- // computeRenderingRanges (cumulative === effectiveScrollPosition の特例) と同じく可視先頭を index+1 に補正する。
2053
- activeVisibleStartIndex = cumulative !== undefined && cumulative === logicalScrollTop ? index + 1 : index
2110
+ activeVisibleStartIndex = resolveVisibleStartRow(fenwickTree, logicalScrollTop, itemCount).index
2054
2111
  }
2055
2112
 
2056
- // Allow for a small buffer in case of slight misalignments or stale refs
2057
2113
  // If the item is strictly above the visible start, it pushes content down.
2058
- // アイテムが(ほぼ)確実に可視領域より上にある場合、コンテンツ全体を押し下げます
2059
- if (activeVisibleStartIndex !== -1 && safeIndex < activeVisibleStartIndex && delta !== 0) {
2114
+ // アイテムが可視領域より上にある場合、コンテンツ全体を押し下げます
2115
+ if (safeIndex < activeVisibleStartIndex && delta !== 0) {
2060
2116
  // Use latestScrollPositionRef as the source of truth for the current position
2061
2117
  // to avoid reading stale DOM values during batched updates (e.g. multiple items resizing at once).
2062
2118
  const currentPanePosition = latestScrollPositionRef.current
@@ -2203,9 +2259,15 @@ const VirtualScrollInner = <T,>(
2203
2259
  }, [itemCount, scrollToIndex])
2204
2260
 
2205
2261
  /**
2206
- * Scrolls to a raw offset while resolving to an index.
2262
+ * Scrolls to a raw logical offset by pinning the first visible row there (`resolveVisibleStartRow`) with the offset of
2263
+ * its top, so the pending alignment is the row `getScrollAnchor` reports: a position on a row boundary pins the row that
2264
+ * starts there at offset 0, never the hidden row above it.
2207
2265
  *
2208
- * オフセットをインデックスに変換しつつスクロール。
2266
+ * 論理オフセットへのスクロール。その位置の先頭の可視行 (`resolveVisibleStartRow`) を、その上端からのオフセットで留めるので、
2267
+ * 保留中の揃えは `getScrollAnchor` が知らせる行になる。行の境界の位置では、そこから始まる行をオフセット 0 で留め、上に
2268
+ * 隠れた行は留めない。
2269
+ *
2270
+ * @param newPosition - Logical position in px (floored, then clamped to the content) / 論理位置 (px。切り捨ててから中身の範囲へクランプ)
2209
2271
  */
2210
2272
  const scrollTo = useCallback(
2211
2273
  (newPosition: number) => {
@@ -2214,43 +2276,33 @@ const VirtualScrollInner = <T,>(
2214
2276
  }
2215
2277
  const total = fenwickTree.getTotal()
2216
2278
  const safePosition = minmax(Math.floor(newPosition), 0, total)
2217
- const { index, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(safePosition, { materializeOption: { materialize: false } })
2218
-
2219
- // Calculate offset relative to item top to ensure precise positioning
2220
- // itemTop = cumulative - currentValue
2221
- const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
2222
- const offset = itemTop - safePosition
2223
-
2224
- scrollToIndex(index, { offset })
2279
+ const startRow = resolveVisibleStartRow(fenwickTree, safePosition, itemCount)
2280
+ scrollToIndex(startRow.index, { offset: startRow.top - safePosition })
2225
2281
  },
2226
2282
  [fenwickTree, itemCount, scrollToIndex],
2227
2283
  )
2228
2284
 
2229
2285
  /**
2230
- * Captures the current top-row anchor for exact position restore across remounts.
2231
- * 再マウント越しの厳密な位置復元のために、現在の先頭可視行アンカーを取得する処理。
2286
+ * Captures the current top-row anchor for exact position restore across remounts: the first visible row that
2287
+ * `resolveVisibleStartRow` resolves (the same row `scrollTo` pins) and the px its top is hidden above the viewport top.
2288
+ * Returns `null` while the list is empty.
2289
+ * 再マウント越しの厳密な位置復元のために、現在の先頭可視行アンカーを取得する処理。行は `resolveVisibleStartRow` が解決する
2290
+ * 先頭の可視行 (`scrollTo` が留める行と同じ) で、空の一覧では `null`。
2232
2291
  *
2233
2292
  * offsetPx は「先頭可視行の上端がビューポート上端より上に隠れている px」(正の値、論理座標)。
2234
2293
  * 復元は initialScrollAnchor へそのまま渡す。可視ウィンドウの行高さは描画のたびに実測が
2235
2294
  * ツリーへ照合されるため、保存時点のアンカーは常に正確で、生 px と違い再マウント後の
2236
2295
  * 推定空間の違いに対して不変 (px 復元は深い位置で別の行に着地する)。
2296
+ *
2297
+ * @returns The anchor, or `null` without rows / アンカー (行が無ければ `null`)
2237
2298
  */
2238
2299
  const getScrollAnchor = useCallback((): { index: number; offsetPx: number } | null => {
2239
2300
  if (itemCount === 0) {
2240
2301
  return null
2241
2302
  }
2242
2303
  const logical = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
2243
- const { index, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(logical, { materializeOption: { materialize: false } })
2244
- if (index === -1) {
2245
- // 末尾越え (推定総高さより深い位置) は最終行アンカーへ丸める
2246
- return { index: itemCount - 1, offsetPx: 0 }
2247
- }
2248
- if (cumulative === logical) {
2249
- // 行の下端がちょうどビューポート上端 = 可視先頭は次の行 (可視域計算と同じ境界規約)
2250
- return { index: minmax(index + 1, 0, itemCount - 1), offsetPx: 0 }
2251
- }
2252
- const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
2253
- return { index, offsetPx: Math.max(0, logical - itemTop) }
2304
+ const startRow = resolveVisibleStartRow(fenwickTree, logical, itemCount)
2305
+ return { index: startRow.index, offsetPx: Math.max(0, logical - startRow.top) }
2254
2306
  }, [fenwickTree, itemCount, resolvedInsets.top])
2255
2307
 
2256
2308
  /**
@@ -2650,6 +2702,12 @@ const VirtualScrollInner = <T,>(
2650
2702
  * ポインタ軸の下限は CSS 側の `.aqvs-scroll-to-edge-overlay[data-visible="false"]
2651
2703
  * .aqvs-scroll-to-edge-button { pointer-events: none }` が担う (配布 CSS 未読込のホストでは
2652
2704
  * `inert` が、`inert` 未実装のブラウザでは CSS が、互いの穴を埋める)。
2705
+ *
2706
+ * ホストがキーボードのスクロールを持つ (`scrollBarOptions.enableArrowButtonTabStops: false`) と、ピルはスクロールバーと
2707
+ * 同じくポインタ専用になる。オーバーレイは見えている間も `aria-hidden="true"` で、ピルは Tab 順に入らず、オーバーレイ
2708
+ * が押下の既定動作を取り消すのでピルの押下はフォーカスを動かさない (click は届く)。
2709
+ *
2710
+ * @returns The overlay, or `null` without the pills / オーバーレイ (ピルを出さないなら `null`)
2653
2711
  */
2654
2712
  const renderOverlay = useCallback(() => {
2655
2713
  if (!enableScrollToTopBottomButtons) {
@@ -2658,16 +2716,17 @@ const VirtualScrollInner = <T,>(
2658
2716
 
2659
2717
  const isVisible = showScrollButtons && scrollDirection !== null
2660
2718
  const isTop = scrollDirection === "up"
2719
+ const pillTabIndex = isVisible && !scrollChromeIsPointerOnly ? 0 : -1
2661
2720
 
2662
2721
  return (
2663
- <div className="aqvs-scroll-to-edge-overlay" data-visible={isVisible} inert={!isVisible}>
2722
+ <div className="aqvs-scroll-to-edge-overlay" data-visible={isVisible} inert={!isVisible} aria-hidden={scrollChromeIsPointerOnly ? true : undefined} onPointerDown={scrollChromeIsPointerOnly ? keepFocusOnPress : undefined}>
2664
2723
  {isTop ? (
2665
2724
  <div className="aqvs-scroll-to-edge-button-container aqvs-scroll-to-edge-button-container-top">
2666
2725
  <button
2667
2726
  type="button"
2668
2727
  className="aqvs-scroll-to-edge-button"
2669
2728
  // 非表示中はタブ順から外す (inert 未実装ブラウザ向けの下限)
2670
- tabIndex={isVisible ? 0 : -1}
2729
+ tabIndex={pillTabIndex}
2671
2730
  onClick={(e) => {
2672
2731
  e.stopPropagation()
2673
2732
  isProgrammaticScrollRef.current = true
@@ -2683,7 +2742,7 @@ const VirtualScrollInner = <T,>(
2683
2742
  type="button"
2684
2743
  className="aqvs-scroll-to-edge-button"
2685
2744
  // 非表示中はタブ順から外す (inert 未実装ブラウザ向けの下限)
2686
- tabIndex={isVisible ? 0 : -1}
2745
+ tabIndex={pillTabIndex}
2687
2746
  onClick={(e) => {
2688
2747
  e.stopPropagation()
2689
2748
  isProgrammaticScrollRef.current = true
@@ -2696,7 +2755,7 @@ const VirtualScrollInner = <T,>(
2696
2755
  )}
2697
2756
  </div>
2698
2757
  )
2699
- }, [enableScrollToTopBottomButtons, showScrollButtons, scrollDirection, scrollToIndex, itemCount, resolvedLabels])
2758
+ }, [enableScrollToTopBottomButtons, scrollChromeIsPointerOnly, showScrollButtons, scrollDirection, scrollToIndex, itemCount, resolvedLabels])
2700
2759
 
2701
2760
  // 量子化アンカー (fix: LayoutUnit/f32 精度対策)。行 top はコンテンツ絶対座標そのままではなく
2702
2761
  // 「絶対座標 - アンカー」で描画し、ラッパー側 translateY にアンカーを足し戻す。
package/src/labels.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * @module labels
3
- * @description Built-in UI chrome label catalog of the package. Covers exactly the seven strings
4
- * the components render on their own: the ScrollBar arrow aria-labels, the VirtualScroll
3
+ * @description Built-in UI chrome label catalog of the package. Covers exactly the eleven strings
4
+ * the components render on their own: the ScrollBar accessible names (per orientation, the two
5
+ * arrows, the `role="scrollbar"` bar and its `role="slider"` thumb), the VirtualScroll
5
6
  * scroll-to-edge pill texts and the empty-state text. Live-region wording stays consumer-owned
6
7
  * (`liveRegion.format` / `liveRegion.buildMessage`) and is not part of this catalog. The default
7
8
  * locale is `"en"`; `"ja"` is the second locale. No language negotiation happens here: hosts map
8
9
  * `navigator.language` (or anything else) to a supported locale themselves.
9
10
  *
10
- * @description パッケージ内蔵の UI クローム文言カタログ。コンポーネント自身が描画する 7 文言
11
- * (ScrollBar 矢印の aria-label、VirtualScroll の端スクロールピル文言、空状態文言) だけを対象とし、
11
+ * @description パッケージ内蔵の UI クローム文言カタログ。コンポーネント自身が描画する 11 文言
12
+ * (向きごとの ScrollBar のアクセシブルネーム — 矢印 2 個・`role="scrollbar"` のバー・その `role="slider"` の
13
+ * つまみ —、VirtualScroll の端スクロールピル文言、空状態文言) だけを対象とし、
12
14
  * ライブリージョンの文言は利用側の所有 (`liveRegion.format` / `liveRegion.buildMessage`) で
13
15
  * 本カタログの対象外。既定ロケールは `"en"`、第 2 ロケールは `"ja"`。言語ネゴシエーションは行わず、
14
16
  * `navigator.language` 等から対応ロケールへの対応付けはホスト側の責務。
@@ -53,6 +55,30 @@ export type VirtualScrollLabels = {
53
55
  * 横 ScrollBar 終端 (右) 矢印ボタンの aria-label。
54
56
  */
55
57
  readonly scrollRight: string
58
+ /**
59
+ * aria-label of the vertical ScrollBar root (`role="scrollbar"`). Assistive technology appends
60
+ * the role ("scroll bar") itself, so the name carries only what tells the two bars apart.
61
+ * 縦 ScrollBar のルート (`role="scrollbar"`) の aria-label。ロール名 (スクロールバー) は支援技術が
62
+ * 付け足すため、名前は 2 本のバーを区別する情報のみ。
63
+ */
64
+ readonly verticalScrollBar: string
65
+ /**
66
+ * aria-label of the horizontal ScrollBar root (`role="scrollbar"`).
67
+ * 横 ScrollBar のルート (`role="scrollbar"`) の aria-label。
68
+ */
69
+ readonly horizontalScrollBar: string
70
+ /**
71
+ * aria-label of the vertical ScrollBar thumb (`role="slider"`), whose value is the scroll
72
+ * position.
73
+ * 縦 ScrollBar のつまみ (`role="slider"`) の aria-label。値はスクロール位置。
74
+ */
75
+ readonly verticalScrollThumb: string
76
+ /**
77
+ * aria-label of the horizontal ScrollBar thumb (`role="slider"`), whose value is the scroll
78
+ * position.
79
+ * 横 ScrollBar のつまみ (`role="slider"`) の aria-label。値はスクロール位置。
80
+ */
81
+ readonly horizontalScrollThumb: string
56
82
  /**
57
83
  * Text of the VirtualScroll scroll-to-top pill, shown when
58
84
  * `scrollBarOptions.enableScrollToTopBottomButtons` is on.
@@ -87,15 +113,32 @@ export type VirtualScrollLabelOverrides = { readonly [K in keyof VirtualScrollLa
87
113
  * overrides are validated against.
88
114
  * {@link VirtualScrollLabels} の全キー (カタログ順)。上書き検証に用いる閉じたキー集合。
89
115
  */
90
- export const VIRTUAL_SCROLL_LABEL_KEYS = ["scrollUp", "scrollDown", "scrollLeft", "scrollRight", "scrollToTop", "scrollToBottom", "noItems"] as const satisfies readonly (keyof VirtualScrollLabels)[]
116
+ export const VIRTUAL_SCROLL_LABEL_KEYS = [
117
+ "scrollUp",
118
+ "scrollDown",
119
+ "scrollLeft",
120
+ "scrollRight",
121
+ "verticalScrollBar",
122
+ "horizontalScrollBar",
123
+ "verticalScrollThumb",
124
+ "horizontalScrollThumb",
125
+ "scrollToTop",
126
+ "scrollToBottom",
127
+ "noItems",
128
+ ] as const satisfies readonly (keyof VirtualScrollLabels)[]
91
129
 
92
130
  // satisfies は部分集合しか保証しないため、キー追加時の登録漏れを型で検出する
93
131
  const keysCoverEveryLabel: [Exclude<keyof VirtualScrollLabels, (typeof VIRTUAL_SCROLL_LABEL_KEYS)[number]>] extends [never] ? true : never = true
94
132
  void keysCoverEveryLabel
95
133
 
96
134
  /**
97
- * Frozen built-in catalogs per locale. `en` is the package's historical English wording.
98
- * ロケールごとの凍結済み内蔵カタログ。`en` はパッケージ従来の英語文言。
135
+ * Frozen built-in catalogs per locale. The arrow, pill and empty-state values of `en` are the
136
+ * package's historical English wording. The bar names state only the orientation, because
137
+ * assistive technology already reads the role as "scroll bar"; the thumb names state what the
138
+ * value measures.
139
+ * ロケールごとの凍結済み内蔵カタログ。`en` の矢印・ピル・空状態の値はパッケージ従来の英語文言。
140
+ * バーの名前は向きのみ (支援技術がロールを既に「スクロールバー」と読むため)、つまみの名前は値が
141
+ * 表すもの。
99
142
  */
100
143
  export const VIRTUAL_SCROLL_LABEL_CATALOGS: Readonly<Record<VirtualScrollLocale, VirtualScrollLabels>> = Object.freeze({
101
144
  en: Object.freeze({
@@ -103,6 +146,10 @@ export const VIRTUAL_SCROLL_LABEL_CATALOGS: Readonly<Record<VirtualScrollLocale,
103
146
  scrollDown: "Scroll down",
104
147
  scrollLeft: "Scroll left",
105
148
  scrollRight: "Scroll right",
149
+ verticalScrollBar: "Vertical",
150
+ horizontalScrollBar: "Horizontal",
151
+ verticalScrollThumb: "Vertical scroll position",
152
+ horizontalScrollThumb: "Horizontal scroll position",
106
153
  scrollToTop: "Top",
107
154
  scrollToBottom: "Bottom",
108
155
  noItems: "No items",
@@ -112,6 +159,10 @@ export const VIRTUAL_SCROLL_LABEL_CATALOGS: Readonly<Record<VirtualScrollLocale,
112
159
  scrollDown: "下へスクロール",
113
160
  scrollLeft: "左へスクロール",
114
161
  scrollRight: "右へスクロール",
162
+ verticalScrollBar: "縦方向",
163
+ horizontalScrollBar: "横方向",
164
+ verticalScrollThumb: "縦スクロール位置",
165
+ horizontalScrollThumb: "横スクロール位置",
115
166
  scrollToTop: "先頭へ",
116
167
  scrollToBottom: "末尾へ",
117
168
  noItems: "項目がありません",
@@ -329,6 +329,22 @@
329
329
  width: 100%;
330
330
  }
331
331
 
332
+ /* 行ラッパーの包含ブロックで、配置の境界 (Chromium の relayout boundary)。窓をずらす 1 段が行を足し引きしても、配置はこの箱から
333
+ * 始まって箱の中で終わり、文書の根まで遡らない。境界になるには flex の子でも grid の子でもないことが要る (ペインの中身は flex の子
334
+ * なので、ラッパーの包含ブロックにしても境界にならない)。この箱は位置指定と、大きさと配置の封じ込めで境界になる。
335
+ * 箱はペインの中身のパディングの辺に付いて幅を合わせ、高さは 0 に固定する: 行は箱の外へはみ出して描かれ (描画は封じ込めないので、
336
+ * 切り取りはペインの中身の overflow: hidden のまま)、箱は当たり判定で背景 (background) の前に立たない。行のはみ出しは封じ込めで
337
+ * インクのはみ出しになるので、ペインの中身のスクロールできる範囲にも数えない。そのため、ブラウザが自分で行を見せるスクロール
338
+ * (オーバースキャンの行の中への Tab・ページ内検索) はペインの中身を動かせず、行の箱が外側のスクローラーの外にあればそちらを動かす */
339
+ .aqvs-items-boundary {
340
+ position: absolute;
341
+ top: 0;
342
+ right: 0;
343
+ left: 0;
344
+ height: 0;
345
+ contain: size layout style;
346
+ }
347
+
332
348
  .aqvs-items-wrapper {
333
349
  position: absolute;
334
350
  width: 100%;
@@ -547,15 +563,47 @@
547
563
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
548
564
  }
549
565
 
550
- /* パッケージ全域・単一所有者 — 本 CSS 初の forced-colors 規則: グラデーション視覚は
551
- * forced-colors で平坦化され、輪郭なしではサークルが不可視になる (描画モード修正 —
552
- * 単体サークルにも輪郭が付くのは登記済みの挙動非変更デルタ)。 */
566
+ /* 強制配色 (forced-colors: active) では、ブラウザがシステム色でない地色をどれも Canvas に置き換える (透明度は残す)。地色だけで
567
+ * 描く部品 (文字色も枠線も輪郭も持たない部品) は周りと区別できなくなるので、どれもここでシステム色の描き方を持つ
568
+ * (forcedColors.spec.tsx がこのファイルから地色だけで描く部品を導いて照合する)。
569
+ * - タップスクロールサークル: グラデーションの視覚は平坦になるので、輪郭で形を残す。
570
+ * - バーとトラック: 縁を GrayText の輪郭で描く (バーは矢印とトラックの下地、トラックはつまみが動く範囲)。
571
+ * - つまみ: ネイティブのスクロールバーと同じくシステム色で塗る。状態の規則 (ホバー・ドラッグ・無効) は同じ選択子でここに
572
+ * 言い直す: 詳細度が同じなので、後に置いたこちらが勝つ。forced-color-adjust: none はホストが塗ったシステム色でない地色を
573
+ * Canvas へ消させない (つまみが溝と同じ色になって消えない)。
574
+ * - サンプルのビジュアルの光と棒: 引いた向きと量を示すので CanvasText で塗る。 */
553
575
  @media (forced-colors: active) {
554
576
  .aqvs-tap-scroll-circle {
555
577
  outline: 1px solid CanvasText;
556
578
  outline-offset: -1px;
557
579
  border-radius: 50%;
558
580
  }
581
+
582
+ .aqvs-scrollbar,
583
+ .aqvs-scrollbar-track {
584
+ outline: 1px solid GrayText;
585
+ outline-offset: -1px;
586
+ }
587
+
588
+ .aqvs-scrollbar-thumb {
589
+ forced-color-adjust: none;
590
+ background-color: CanvasText;
591
+ }
592
+
593
+ .aqvs-scrollbar-thumb[data-thumb-state="hover"],
594
+ .aqvs-scrollbar-thumb[data-thumb-state="dragging"] {
595
+ background-color: Highlight;
596
+ }
597
+
598
+ .aqvs-scrollbar-thumb[data-thumb-state="disabled"] {
599
+ background-color: GrayText;
600
+ }
601
+
602
+ .aqvs-sample-visual-highlight,
603
+ .aqvs-sample-visual-rod {
604
+ forced-color-adjust: none;
605
+ background-color: CanvasText;
606
+ }
559
607
  }
560
608
 
561
609
  /* 動きを減らす設定 (prefers-reduced-motion: reduce) では、パッケージが動かす部品はどれも遷移もアニメーションも持たず、