@aiquants/virtualscroll 2.3.0 → 2.5.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.
@@ -1,8 +1,10 @@
1
- import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react"
1
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
2
2
  import { twMerge } from "tailwind-merge"
3
3
  import { Logger } from "./logger.ts"
4
4
  import { ScrollBar, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_CANCEL_EVENT } from "./ScrollBar.tsx"
5
5
  import { minmax } from "./utils.ts"
6
+ import { resolveWheelAxes } from "./wheelAxes.ts"
7
+ import { markWheelConsumed, wasWheelConsumed } from "./wheelConsumption.ts"
6
8
 
7
9
  /**
8
10
  * Props for the ScrollPane component.
@@ -25,8 +27,32 @@ export type ScrollPaneProps = {
25
27
  children: (scrollPosition: number) => React.ReactNode
26
28
  /** The total size of the content. / コンテンツの総サイズ。 */
27
29
  contentSize: number
28
- /** The size of the visible area. / 表示領域のサイズ。 */
29
- viewportSize: number
30
+ /**
31
+ * Size of the visible band (px). Omit it to let the pane measure its own band.
32
+ * 可視帯の高さ (px)。省略するとペインが自分の帯を計測する。
33
+ *
34
+ * ❗ **省略が既定の使い方である。** この値は帯のクリップ窓・最大スクロール位置・つまみ写像・
35
+ * 描画枚数・スクロール可否のすべての基準であり、実際の帯と食い違うと 5 通りの壊れ方が
36
+ * **例外も警告も出さずに**発生する (はみ出し / 死に帯 / 末尾へ到達不能 / つまみが視野外へ /
37
+ * ホイールとドラッグの無効化)。自分で測れるものを消費側に渡させると、同じ正解を各画面で
38
+ * 書き直すことになり、実際に推測値や定数が代入される。
39
+ *
40
+ * 省略時は `.aqvs-scroll-pane-content` の実高さ (CSS の `height: 100%` が決める) を
41
+ * `ResizeObserver` で追跡する。**ホスト側がルート要素の高さを確定させること**が前提で
42
+ * (`h-full` / `flex-1` + `min-h-0` / 明示 px / `position: absolute` など)、高さが auto の
43
+ * ホストでは帯が 0 のままになる (開発ビルドで警告する)。
44
+ *
45
+ * 明示する正当なケースは「帯の高さを計測ではなく算出で知っている」場合のみである
46
+ * (例: ドロップダウンが件数 × 行高から高さを決める)。
47
+ */
48
+ viewportSize?: number
49
+ /**
50
+ * Notified when the self-measured viewport size changes (not called while `viewportSize` is given).
51
+ * 自己計測したビューポート高さが変化したときに通知する (`viewportSize` 指定時は呼ばれない)。
52
+ *
53
+ * 帯の数値を必要とする親 (`VirtualScroll` は描画枚数とアライン計算に使う) がこの口で受け取る。
54
+ */
55
+ onViewportSizeChange?: (viewportSize: number) => void
30
56
  /** The width of the scrollbar. / スクロールバーの幅。 */
31
57
  scrollBarWidth?: number
32
58
  /** Whether grabbing the scrollbar thumb is allowed. / スクロールバーのつまみ操作を許可するかどうか。 */
@@ -212,6 +238,16 @@ export type ScrollPaneHandle = {
212
238
  getScrollPosition: () => number
213
239
  getContentSize: () => number
214
240
  getViewportSize: () => number
241
+ /**
242
+ * Applies one wheel event with the pane's own rules; returns whether it was consumed.
243
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す。
244
+ *
245
+ * ペインの**外**に置いた要素 (列ヘッダー帯など) のホイールを、ペイン内と寸分違わぬ意味論で
246
+ * 流し込むための唯一の口。軸分解・速度倍率・スクロール可否・横委譲先・慣性停止のすべてが
247
+ * ペイン側の 1 箇所で決まるため、消費側が規則を書き直したり食い違わせたりできない。
248
+ * 直接使うより `useWheelBridge` (passive:false 登録と後始末込み) を推奨する。
249
+ */
250
+ applyWheel: (event: WheelEvent) => boolean
215
251
  }
216
252
 
217
253
  /**
@@ -224,7 +260,8 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
224
260
  {
225
261
  children,
226
262
  contentSize,
227
- viewportSize,
263
+ viewportSize: viewportSizeProp,
264
+ onViewportSizeChange,
228
265
  scrollBarWidth = 12,
229
266
  enableThumbDrag = true,
230
267
  enableTrackClick = true,
@@ -256,6 +293,140 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
256
293
  const scrollPositionRef = useRef(Number.isFinite(initialScrollPosition) ? Math.max(0, initialScrollPosition) : 0)
257
294
  const scrollContainerRef = useRef<HTMLDivElement>(null)
258
295
  const contentAreaRef = useRef<HTMLDivElement>(null)
296
+
297
+ // viewportSize 未指定なら自分の帯を計測する。以降のすべての計算はこの解決済み値 (viewportSize) を使う。
298
+ // ❗ ScrollPane が持つ唯一の State であり、変化するのは**ホストの帯が変わったときだけ**である
299
+ // (スクロール中には一切変化しない)。ホットパスで setState しないという設計 (§1.1) は保たれる。
300
+ const isSelfMeasuringViewport = viewportSizeProp === undefined
301
+ const [measuredViewportSize, setMeasuredViewportSize] = useState(0)
302
+ const viewportSize = viewportSizeProp ?? measuredViewportSize
303
+ // 同一内容の警告を繰り返さないための最後の警告キー。
304
+ const lastViewportWarningRef = useRef<string | null>(null)
305
+
306
+ /**
307
+ * Tracks the real height of the content band so the pane can size itself.
308
+ * ペインが自分の帯の実高さを追跡し、自己サイズ決定できるようにする副作用。
309
+ *
310
+ * 計測対象は**コンテンツ要素**である。自己計測モードではこの要素へ高さのインラインスタイルを
311
+ * 書かず、CSS の `height: 100%` (= ルートの確定高さ) がレイアウトを所有するため、
312
+ * 「計測した数値を書き戻して自分のサイズを変える」フィードバックループが構造的に起こらない。
313
+ * ルート要素を測ると、消費側がルートへ付けた padding の分だけ帯を過大評価してしまう。
314
+ *
315
+ * ❗ **0 の計測は採用しない。** `display: none` のタブに置かれている等でレイアウトされて
316
+ * いないとき `offsetHeight` は 0 になる。0 を採ると `isScrollable` が偽になり、サイズ調整
317
+ * エフェクト (§9) が `scrollTo(0)` を発行して**タブ切替でスクロール位置が失われる**。
318
+ * 0 は「帯が 0」ではなく「まだ測れない」と解釈し、直前の値を保つ。
319
+ *
320
+ * 目的: 自己計測モードで帯の高さを State へ反映する。
321
+ * 依存関係: [contentSize, isSelfMeasuringViewport]
322
+ * クリーンアップ: ResizeObserver を切断する。
323
+ */
324
+ useLayoutEffect(() => {
325
+ if (!isSelfMeasuringViewport) {
326
+ return
327
+ }
328
+ const element = contentAreaRef.current
329
+ if (!element) {
330
+ return
331
+ }
332
+ // ResizeObserver の通知は layout の後・paint の前に届くため、ここでの読み取りは強制リフローにならない。
333
+ // transform: scale(...) の下でもレイアウト px を得るため rect ではなく offsetHeight を使う (整数丸め)。
334
+ const applyMeasuredSize = () => {
335
+ const measured = element.offsetHeight
336
+ if (measured > 0) {
337
+ setMeasuredViewportSize((previous) => (previous === measured ? previous : measured))
338
+ return
339
+ }
340
+ // offsetParent が null なら display: none 等で非表示 = 正常な未計測状態なので黙る。
341
+ // レイアウトされているのに 0 の場合はホストが高さを確定させていない (自己計測の前提破り)。
342
+ if (element.offsetParent !== null && contentSize > 0 && lastViewportWarningRef.current !== "zero") {
343
+ lastViewportWarningRef.current = "zero"
344
+ Logger.warn("[ScrollPane] The self-measured viewport is 0px. Give the scroll pane's host element a definite height (e.g. h-full, flex-1 + min-h-0, an explicit px height), or pass viewportSize explicitly.")
345
+ }
346
+ }
347
+ applyMeasuredSize()
348
+ if (typeof ResizeObserver !== "function") {
349
+ return
350
+ }
351
+ const observer = new ResizeObserver(applyMeasuredSize)
352
+ observer.observe(element)
353
+ return () => {
354
+ observer.disconnect()
355
+ }
356
+ }, [contentSize, isSelfMeasuringViewport])
357
+
358
+ /**
359
+ * Publishes the self-measured viewport size to the owner.
360
+ * 自己計測したビューポート高さを所有者へ通知する副作用。
361
+ *
362
+ * ❗ layout effect である。`VirtualScroll` は描画枚数とアライン計算にこの数値を使うため、
363
+ * paint 前に通知しないと初回フレームだけ 1 行しか描かれない見た目になる。
364
+ *
365
+ * 目的: 親が帯の数値を保持できるようにする。
366
+ * 依存関係: [isSelfMeasuringViewport, measuredViewportSize, onViewportSizeChange]
367
+ * クリーンアップ: 不要。
368
+ */
369
+ useLayoutEffect(() => {
370
+ if (!isSelfMeasuringViewport) {
371
+ return
372
+ }
373
+ onViewportSizeChange?.(measuredViewportSize)
374
+ }, [isSelfMeasuringViewport, measuredViewportSize, onViewportSizeChange])
375
+
376
+ /**
377
+ * Warns when an explicitly passed viewportSize disagrees with the host's real band.
378
+ * 明示された viewportSize がホストの実際の帯と食い違うときに警告する副作用。
379
+ *
380
+ * ❗ 食い違いは**例外も視覚的な破綻もなく**進行し、はみ出し / 死に帯 / 末尾へ到達不能 /
381
+ * つまみが視野外 / ホイールとドラッグの無効化を引き起こす。検知できるのはここだけである。
382
+ *
383
+ * 比較対象はルート要素の `clientHeight` である。コンテンツ要素は明示モードでは
384
+ * `height: viewportSize` を書かれているため測っても同義反復になる。ホストが高さを
385
+ * 確定させていない (auto) 場合はルートも `viewportSize` に一致するので警告は出ない
386
+ * — その場合は食い違い自体が存在しないため正しい。
387
+ *
388
+ * `Logger.warn` を無条件に使う (NODE_ENV で切らない): 単一ビルドを配布しており
389
+ * `process.env` の置換は消費側のバンドラ任せで信頼できないうえ、既存の誤用警告
390
+ * (`useLruCache` の無効容量、`scrollTo` の非有限値) と同じ扱いに揃えるため。
391
+ *
392
+ * 目的: 帯との食い違いを検知して通知する。
393
+ * 依存関係: [isSelfMeasuringViewport, viewportSize]
394
+ * クリーンアップ: ResizeObserver を切断する。
395
+ */
396
+ useLayoutEffect(() => {
397
+ if (isSelfMeasuringViewport) {
398
+ return
399
+ }
400
+ const element = scrollContainerRef.current
401
+ if (!element) {
402
+ return
403
+ }
404
+ const warnOnMismatch = () => {
405
+ const actual = element.clientHeight
406
+ // 0 は非表示 (display: none 等) の未計測状態なので比較しない。
407
+ if (actual <= 0 || Math.abs(actual - viewportSize) <= 1) {
408
+ return
409
+ }
410
+ const key = `${viewportSize}/${actual}`
411
+ if (lastViewportWarningRef.current === key) {
412
+ return
413
+ }
414
+ lastViewportWarningRef.current = key
415
+ Logger.warn(
416
+ `[ScrollPane] viewportSize=${viewportSize} but the host band is ${actual}px. The clip window, max scroll position, thumb mapping, rendered row count and scrollability all derive from viewportSize, so they are now wrong. Omit viewportSize to let the pane measure itself.`,
417
+ )
418
+ }
419
+ warnOnMismatch()
420
+ if (typeof ResizeObserver !== "function") {
421
+ return
422
+ }
423
+ const observer = new ResizeObserver(warnOnMismatch)
424
+ observer.observe(element)
425
+ return () => {
426
+ observer.disconnect()
427
+ }
428
+ }, [isSelfMeasuringViewport, viewportSize])
429
+
259
430
  // 横委譲コールバックは ref 経由で参照し、wheel リスナの再登録を避ける。
260
431
  const onWheelHorizontalRef = useRef(onWheelHorizontal)
261
432
  useEffect(() => {
@@ -306,6 +477,10 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
306
477
  // contentSize と viewportSize を ref として保持
307
478
  // const sizeRef = useRef({ contentSize, viewportSize })
308
479
 
480
+ // ❗ render 本体で代入する。layout effect へ移すと、サイズ変更と同じコミット内で走る
481
+ // 他の layout effect からの scrollTo が 1 コミット古い寸法でクランプされ、タップスクロールの
482
+ // 走破が短くなる (実機 E2E の等速性テストがサンプル数不足で落ちる形で退行が出る)。
483
+ // 既知の代償は下の wheelPolicyRef のコメントを参照。
309
484
  const sizeRef = useRef({ contentSize, viewportSize })
310
485
  sizeRef.current = { contentSize, viewportSize }
311
486
 
@@ -566,73 +741,132 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
566
741
  }
567
742
  }, [isScrollable, scrollTo, contentSize, viewportSize])
568
743
 
569
- useEffect(() => {
570
- // ホイールイベントのハンドラ
571
- const handleWheel = (event: WheelEvent) => {
572
- // ctrl+wheel はブラウザのズーム (ピンチズーム/ctrl+shift+ホイール等) なので横取りせず常に素通しする
573
- if (event.ctrlKey) {
574
- return
575
- }
744
+ // ホイール適用の方針値。保持されたハンドル越しの呼び出しでも凍結させないため ref で持つ。
745
+ //
746
+ // **代入のタイミングは `sizeRef` と必ず揃える。** 片方だけを layout effect にすると、
747
+ // 中断されたトランジション中に `applyWheel` は「スクロール可能」と判断して消費する一方、
748
+ // `scrollTo` は 1 コミット古い寸法でクランプして 0 を返す、という最悪の組み合わせになる
749
+ // (実測: 5,000 行の一覧が 1 ノッチで先頭へ飛び、以後 0 に貼り付く)。
750
+ //
751
+ // ❗ **既知の限界 (未修正)**: render 本体での ref 書き込みは、中断されたトランジションで
752
+ // **コミットされなかった値**を残す。その間のホイールは画面に出ていない寸法で判断されるため、
753
+ // 「スクロール可能な一覧なのにホイールが効かない」状態が保留中だけ起きる。両 ref を
754
+ // layout effect へ移すのが筋だが、それだけではタップスクロールの走破が 1 コミット遅れて
755
+ // 短くなる退行が出る (実機 E2E で実証済み)。恒久対応にはクランプ経路の見直しが必要。
756
+ const wheelPolicyRef = useRef({ isScrollable, viewportSize, wheelSpeedMultiplier })
757
+ wheelPolicyRef.current = { isScrollable, viewportSize, wheelSpeedMultiplier }
576
758
 
577
- // Shiftキー押下による軸変換(deltaX=0, deltaY!=0) または 通常のdeltaX/deltaYから横優勢判定
578
- const usesShiftAxis = event.shiftKey && event.deltaX === 0
579
- const horizontalDelta = usesShiftAxis ? event.deltaY : event.deltaX
580
- const verticalDelta = usesShiftAxis ? 0 : event.deltaY
581
- const isHorizontalDominant = horizontalDelta !== 0 && Math.abs(horizontalDelta) >= Math.abs(verticalDelta)
759
+ /**
760
+ * Applies one wheel event with the pane's own rules and reports whether it was consumed.
761
+ *
762
+ * ペイン自身の規則で 1 つのホイールイベントを適用し、消費したかどうかを返す処理。
763
+ *
764
+ * ❗ **ホイールの意味論はここが単一の真実である。** ペイン根に張るリスナーも、ペインの外の要素へ
765
+ * 同じ意味論を与える `useWheelBridge` も、この 1 つの関数を通す。軸分解 (`resolveWheelAxes`) だけを
766
+ * 共有して残り (速度倍率・スクロール可否・横委譲先・慣性停止) を各所に書くと、同じグリッドなのに
767
+ * 「ヘッダー帯の上だけスクロールが遅い」といった食い違いが静かに生まれる (橋渡し側にも設定口を
768
+ * 置いた実装途中の版で実測: 倍率 3 のとき同じ 1 ノッチがペイン上 30px / 帯の上 10px)。
769
+ *
770
+ * 判定順序そのものが仕様:
771
+ *
772
+ * 0. 本パッケージが既に適用済みの印があれば何もしない (ペインの入れ子・祖先の橋渡しでの二重適用防止)
773
+ * 1. ctrl+wheel はブラウザのズームなので横取りせず素通しする
774
+ * 2. 横優勢 (shift+ホイールを含む) は `onWheelHorizontal` へ委譲。未指定なら消費せず祖先へ委ねる
775
+ * 3. スクロール不能なら消費しない
776
+ * 4. px 換算後の縦成分が 0 なら消費しない (適用できる量が無いのに祖先のスクロールを奪わない)
777
+ * 5. 消費済みの印 + `preventDefault()` + 慣性停止 + 速度倍率を掛けて相対スクロール
778
+ *
779
+ * @param event - The wheel event to apply / 適用するホイールイベント
780
+ * @returns True when this pane applied the event / このペインが適用した場合に true。
781
+ * ❗ 適用時は `preventDefault()` も呼ぶが、`cancelable` でないイベント
782
+ * (合成イベントの既定) では preventDefault は効かないため、返値は
783
+ * 「`defaultPrevented` になったか」ではなく「**適用したか**」を意味する
784
+ */
785
+ const applyWheel = useCallback(
786
+ (event: WheelEvent): boolean => {
787
+ // ❗ **このペイン群が既に消費したイベントは二度と適用しない。** 冪等性の規則を
788
+ // 適用シームの内側に置くことで、どの呼び出し元 (ペイン根のリスナー / useWheelBridge /
789
+ // 消費側の直接呼び出し) からも同じ保証が効く。
790
+ //
791
+ // ❗ `event.defaultPrevented` で代用してはならない。あれは「誰かが止めた」であって
792
+ // 「我々が消費した」ではない。ページ全体のスクロールロック (`document` に capture +
793
+ // preventDefault を張る定番の実装) と区別できず、ロック中に一覧が
794
+ // **まったくスクロールできなくなる** (実測)。
795
+ if (wasWheelConsumed(event)) {
796
+ return false
797
+ }
798
+ // ❗ 値は ref から読む。lexical に閉じ込めると、消費側が保持したハンドルが
799
+ // **その時点の方針を凍結**する (実測: 一覧が絞り込みでスクロール不能になった後も
800
+ // 保持済みハンドルは preventDefault し続け、ページのスクロールを殺す)。
801
+ // `VirtualScrollHandle.applyWheel` は毎回 ref を辿るため凍結しない。両者の
802
+ // 非対称は `WheelBridgeTarget` が両方を等価に扱う設計と噛み合わない。
803
+ const { isScrollable: currentIsScrollable, viewportSize: currentViewportSize, wheelSpeedMultiplier: currentWheelSpeedMultiplier } = wheelPolicyRef.current
804
+ const axes = resolveWheelAxes(event, currentViewportSize)
805
+
806
+ // ❗ ctrl+wheel はブラウザのズーム (ピンチズーム/ctrl+shift+ホイール等) なので横取りせず常に素通しする。
807
+ // 解決器はデルタを潰さないため、この判定を外すと ctrl+shift+ホイールを横スクロールとして食う
808
+ if (axes.isZoomGesture) {
809
+ return false
810
+ }
582
811
 
583
- if (isHorizontalDominant) {
812
+ if (axes.isHorizontalDominant) {
584
813
  // 横委譲コールバックが指定されている場合は横スクロールとして処理
585
814
  if (onWheelHorizontalRef.current) {
815
+ markWheelConsumed(event)
586
816
  event.preventDefault()
587
817
  stopInertia()
588
- let deltaX = horizontalDelta
589
- if (event.deltaMode === 1) {
590
- deltaX *= 16 // DOM_DELTA_LINE
591
- } else if (event.deltaMode === 2) {
592
- deltaX *= viewportSize // DOM_DELTA_PAGE
593
- }
594
- onWheelHorizontalRef.current(deltaX)
818
+ onWheelHorizontalRef.current(axes.horizontalDelta)
819
+ return true
595
820
  }
596
821
  // ハンドラ未指定時であっても、横優勢スクロールや Shift+ホイール は
597
822
  // 縦スクロールとして横取りせず、親要素のネイティブ横スクロールへ委ねる
598
- return
823
+ return false
599
824
  }
600
825
 
601
- if (!isScrollable) {
602
- return
826
+ if (!currentIsScrollable) {
827
+ return false
603
828
  }
604
829
 
605
830
  // 縦方向の移動が無いホイール(水平スワイプ等)は祖先のスクロールへ委ねる
606
831
  // A wheel event without vertical delta (e.g. horizontal swipe) is left to ancestor scrolling.
607
- if (event.deltaY === 0) {
608
- return
832
+ //
833
+ // ❗ 判定は **px 換算後** の値で行う。`deltaMode: DOM_DELTA_PAGE` かつ帯が 0 のとき
834
+ // (自己計測の初回コミット等) 換算後は 0 になり、適用しても 1px も動かない。ここで
835
+ // `event.deltaY` (生値) を見ると preventDefault だけして何も起きず、**祖先のスクロールも
836
+ // 奪う**死角になる。「適用できる量が無いなら消費しない」に揃える。
837
+ if (axes.verticalDelta === 0) {
838
+ return false
609
839
  }
610
840
 
841
+ markWheelConsumed(event)
611
842
  event.preventDefault()
612
-
613
843
  stopInertia()
614
844
 
615
- let deltaY = event.deltaY
616
-
617
- // deltaMode に応じてスクロール量を調整
618
- if (event.deltaMode === 1) {
619
- // DOM_DELTA_LINE: 行単位のスクロール
620
- const lineHeight = 16 // 1行のおおよその高さ (ピクセル)
621
- deltaY *= lineHeight
622
- } else if (event.deltaMode === 2) {
623
- // DOM_DELTA_PAGE: ページ単位のスクロール
624
- deltaY *= viewportSize // ビューポートの高さを基準にスクロール
625
- }
626
-
627
- if (wheelSpeedMultiplier !== 1) {
628
- deltaY *= wheelSpeedMultiplier
845
+ let deltaY = axes.verticalDelta
846
+ if (currentWheelSpeedMultiplier !== 1) {
847
+ deltaY *= currentWheelSpeedMultiplier
629
848
  }
630
849
 
631
850
  // ホットパスのためログ引数はサンクで遅延評価し、抑制時は DOM 読み取り (scrollTop) も発生させない
632
- Logger.debug("[ScrollPane] wheel event", () => ({ deltaY, scrollPosition: scrollPositionRef.current, wheelSpeedMultiplier, deltaMode: event.deltaMode, scrollTop: contentAreaRef.current?.scrollTop }))
851
+ Logger.debug("[ScrollPane] wheel event", () => ({ deltaY, scrollPosition: scrollPositionRef.current, wheelSpeedMultiplier: currentWheelSpeedMultiplier, deltaMode: event.deltaMode, scrollTop: contentAreaRef.current?.scrollTop }))
633
852
 
634
853
  // スクロール位置を更新
635
854
  scrollTo((prev) => prev + deltaY)
855
+ return true
856
+ },
857
+ [scrollTo, stopInertia],
858
+ )
859
+
860
+ useEffect(() => {
861
+ // ホイールイベントのハンドラ (適用規則そのものは applyWheel が持つ)
862
+ //
863
+ // ❗ ここに `defaultPrevented` のガードを置いてはならない。ページ全体のスクロールロック
864
+ // (`document` に capture + preventDefault を張る定番の実装) を掛けただけで一覧が
865
+ // **まったくスクロールできなくなる** (実測: ガード無しではスクロールできる)。
866
+ // 二重適用の防止は `applyWheel` 内の「自分が消費した印」が担う (ペインの入れ子・
867
+ // 祖先の橋渡しのいずれも、印を見て 2 回目を弾く)。
868
+ const handleWheel = (event: WheelEvent) => {
869
+ applyWheel(event)
636
870
  }
637
871
 
638
872
  /**
@@ -666,7 +900,7 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
666
900
  scrollContainer.removeEventListener("pointerdown", handlePaneInteraction, { capture: true })
667
901
  }
668
902
  }
669
- }, [isScrollable, scrollTo, stopInertia, viewportSize, wheelSpeedMultiplier])
903
+ }, [applyWheel, stopInertia])
670
904
 
671
905
  useImperativeHandle(
672
906
  ref,
@@ -678,8 +912,9 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
678
912
  getScrollPosition: () => scrollPositionRef.current,
679
913
  getContentSize: () => contentSize,
680
914
  getViewportSize: () => viewportSize,
915
+ applyWheel,
681
916
  }),
682
- [scrollTo, contentSize, viewportSize, stopInertia],
917
+ [scrollTo, contentSize, viewportSize, stopInertia, applyWheel],
683
918
  )
684
919
 
685
920
  const id = useId()
@@ -1225,8 +1460,14 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
1225
1460
  [scrollTo, stopInertia],
1226
1461
  )
1227
1462
 
1463
+ // ❗ 自己計測モードではルートに `data-self-measured` を出し、パッケージ CSS の
1464
+ // `.aqvs-scroll-pane[data-self-measured="true"] { height: 100% }` でホストの帯まで伸ばす。
1465
+ // 明示モードでは content のインライン高さがルートを押し広げるためルート自身に高さは不要だが、
1466
+ // 高さを書かない自己計測モードでは「ルート auto → content の `height: 100%` が auto へ解決
1467
+ // → 帯 0」に潰れる (実ブラウザで実測)。インラインで書かず data 属性 + クラスにするのは、
1468
+ // 消費側が CSS で上書きできる状態を保つため (状態は data-* とパッケージ CSS で表現する方針)。
1228
1469
  return (
1229
- <div ref={scrollContainerRef} data-testid={testId} className={twMerge("aqvs-scroll-pane", className)} style={style}>
1470
+ <div ref={scrollContainerRef} data-testid={testId} data-self-measured={isSelfMeasuringViewport ? "true" : undefined} className={twMerge("aqvs-scroll-pane", className)} style={style}>
1230
1471
  <div
1231
1472
  ref={contentAreaRef}
1232
1473
  className={twMerge("aqvs-scroll-pane-content")}
@@ -1235,7 +1476,11 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
1235
1476
  // コンテンツ領域には prop が無いため、パッケージ側で固定値を出す
1236
1477
  data-testid="aqvs-scroll-pane-content"
1237
1478
  style={{
1238
- height: viewportSize,
1479
+ // ❗ 自己計測モードでは高さを書かない。CSS の `height: 100%` (= ルートの確定高さ) が
1480
+ // レイアウトを所有することで、「計測した値を書き戻して自分のサイズを変える」
1481
+ // フィードバックループが構造的に起こらず、初回 paint の時点でも帯は実寸で正しい
1482
+ // (数値の反映だけが 1 コミット遅れる)。明示モードは従来どおり px を書く。
1483
+ height: isSelfMeasuringViewport ? undefined : viewportSize,
1239
1484
  paddingTop: resolvedInsets.top,
1240
1485
  paddingBottom: resolvedInsets.bottom,
1241
1486
  // スクロール不能時 (contentSize <= viewportSize) は touch-action を通常に戻し、