@aiquants/virtualscroll 3.0.0 → 3.1.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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @module residualQuantizer
3
+ * @description Residual-carrying delta quantizer for consumers that snap scroll positions to a
4
+ * fixed quantum (row height, column width). High-resolution trackpads emit many sub-quantum
5
+ * deltas; a consumer that rounds each delta independently rounds every one of them to zero and
6
+ * the list never moves. This accumulator carries the sub-quantum remainder across events and
7
+ * emits whole quanta as soon as they accumulate.
8
+ *
9
+ * @description スクロール位置を固定の量子 (行高・列幅) へスナップする消費側のための、
10
+ * 残差持ち越し型のデルタ量子化器。高解像度トラックパッドは 1 量子未満のデルタを大量に発行するため、
11
+ * イベントごとに独立して丸める消費側では**すべてが 0 に丸められ、ゆっくり撫でると 1px も動かない**。
12
+ * 本量子化器は量子未満の余りをイベントを跨いで持ち越し、貯まった分だけ量子単位で放出する。
13
+ *
14
+ * ❗ **時間は扱わない (意図的)。** アイドルタイムアウトでの残差破棄は「いつ捨てるか」の方針が
15
+ * 消費側の UI ごとに異なり、内蔵すると幽霊スクロール (古い残差が後から発火) か不感帯のどちらかを
16
+ * パッケージが押し付けることになる。時間起点の破棄が要る消費側は、自前のタイマーから
17
+ * {@link ResidualQuantizer.reset} を呼ぶこと。既定の防御は**方向反転での残差破棄**のみで、
18
+ * これは時間に依存せず「逆へ動かし始めたのに古い残差が先に順方向へ発火する」事故だけを塞ぐ。
19
+ */
20
+ /**
21
+ * Options for {@link createResidualQuantizer}.
22
+ * {@link createResidualQuantizer} のオプション。
23
+ */
24
+ export type ResidualQuantizerOptions = {
25
+ /**
26
+ * Quantum (px) each emission is a multiple of. Must be finite and positive — anything else
27
+ * throws at creation time (fail fast; a silent default would hide the caller's bug).
28
+ * 放出量の単位となる量子 (px)。有限かつ正であること — それ以外は生成時に throw する
29
+ * (Fail Fast。既定値へ黙って読み替えると呼び出し側のバグを隠す)。
30
+ */
31
+ quantum: number;
32
+ /**
33
+ * Drop the carried residue when the incoming delta's direction opposes it (default: `true`).
34
+ * Without this, scrolling +20px (quantum 24, nothing emitted) and then reversing emits the
35
+ * first reverse quantum "late" — the stale forward residue eats part of the reverse motion.
36
+ * 入力デルタの向きが持ち越し残差と逆のとき残差を捨てる (既定 `true`)。捨てないと、
37
+ * +20px (量子 24 で未放出) の後に反転したとき、古い順方向残差が逆方向の動きを食い、
38
+ * 最初の逆方向量子の発火が「遅れて」体感される。
39
+ */
40
+ resetOnDirectionChange?: boolean;
41
+ };
42
+ /**
43
+ * A quantizer instance. One instance per quantized axis — sharing one across axes mixes residues.
44
+ * 量子化器インスタンス。量子化する軸ごとに 1 つ持つこと — 軸間で共有すると残差が混線する。
45
+ */
46
+ export type ResidualQuantizer = {
47
+ /**
48
+ * Accumulates `delta` and returns the emission: a multiple of the quantum (possibly 0),
49
+ * signed toward the accumulated direction. Non-finite deltas AND deltas whose magnitude
50
+ * exceeds `Number.MAX_SAFE_INTEGER` emit 0 and leave the residue untouched — beyond 2^53
51
+ * the float product stops being an EXACT multiple of the quantum, which is precisely the
52
+ * property a grid-snapping consumer relies on.
53
+ * `delta` を蓄積し、放出量 (量子の倍数。0 のこともある) を蓄積方向の符号で返す。
54
+ * 非有限のデルタ、および絶対値が `Number.MAX_SAFE_INTEGER` を超えるデルタは 0 を返し
55
+ * 残差を汚さない — 2^53 超では浮動小数点積が量子の**正確な**倍数でなくなり、グリッド
56
+ * スナップ消費側が依拠する性質そのものが壊れるため。
57
+ *
58
+ * @param delta - Incoming delta (px) / 入力デルタ (px)
59
+ * @returns Emitted amount (multiple of the quantum) / 放出量 (量子の倍数)
60
+ */
61
+ push: (delta: number) => number;
62
+ /**
63
+ * Returns the carried residue without mutating it (|residue| < quantum).
64
+ * 持ち越し中の残差を変更せずに返す (|残差| < 量子)。
65
+ *
66
+ * @returns Current residue (px) / 現在の残差 (px)
67
+ */
68
+ peekResidue: () => number;
69
+ /**
70
+ * Clears the residue. Call from consumer-side idle timers, on pointer leave, or when the
71
+ * quantized target (row/column layout) changes under the accumulator.
72
+ * 残差を破棄する。消費側のアイドルタイマー・ポインタ離脱・量子化対象 (行/列レイアウト) の
73
+ * 変更時に呼ぶ。
74
+ */
75
+ reset: () => void;
76
+ };
77
+ /**
78
+ * Creates a residual-carrying delta quantizer.
79
+ * 残差持ち越し型のデルタ量子化器を生成する処理。
80
+ *
81
+ * ```ts
82
+ * const quantizer = createResidualQuantizer({ quantum: rowHeight })
83
+ * const handleWheelVertical = (deltaY: number) => {
84
+ * const emitted = quantizer.push(deltaY)
85
+ * if (emitted !== 0) {
86
+ * snapScrollBy(emitted) // 常に量子の倍数
87
+ * }
88
+ * }
89
+ * ```
90
+ *
91
+ * @param options - Quantizer options / 量子化器のオプション
92
+ * @returns A new quantizer instance / 新しい量子化器インスタンス
93
+ * @throws {TypeError} When `quantum` is not a finite positive number / `quantum` が有限の正数でない場合
94
+ */
95
+ export declare const createResidualQuantizer: (options: ResidualQuantizerOptions) => ResidualQuantizer;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @module residualQuantizer
3
+ * @description Residual-carrying delta quantizer for consumers that snap scroll positions to a
4
+ * fixed quantum (row height, column width). High-resolution trackpads emit many sub-quantum
5
+ * deltas; a consumer that rounds each delta independently rounds every one of them to zero and
6
+ * the list never moves. This accumulator carries the sub-quantum remainder across events and
7
+ * emits whole quanta as soon as they accumulate.
8
+ *
9
+ * @description スクロール位置を固定の量子 (行高・列幅) へスナップする消費側のための、
10
+ * 残差持ち越し型のデルタ量子化器。高解像度トラックパッドは 1 量子未満のデルタを大量に発行するため、
11
+ * イベントごとに独立して丸める消費側では**すべてが 0 に丸められ、ゆっくり撫でると 1px も動かない**。
12
+ * 本量子化器は量子未満の余りをイベントを跨いで持ち越し、貯まった分だけ量子単位で放出する。
13
+ *
14
+ * ❗ **時間は扱わない (意図的)。** アイドルタイムアウトでの残差破棄は「いつ捨てるか」の方針が
15
+ * 消費側の UI ごとに異なり、内蔵すると幽霊スクロール (古い残差が後から発火) か不感帯のどちらかを
16
+ * パッケージが押し付けることになる。時間起点の破棄が要る消費側は、自前のタイマーから
17
+ * {@link ResidualQuantizer.reset} を呼ぶこと。既定の防御は**方向反転での残差破棄**のみで、
18
+ * これは時間に依存せず「逆へ動かし始めたのに古い残差が先に順方向へ発火する」事故だけを塞ぐ。
19
+ */
20
+ /**
21
+ * Options for {@link createResidualQuantizer}.
22
+ * {@link createResidualQuantizer} のオプション。
23
+ */
24
+ export type ResidualQuantizerOptions = {
25
+ /**
26
+ * Quantum (px) each emission is a multiple of. Must be finite and positive — anything else
27
+ * throws at creation time (fail fast; a silent default would hide the caller's bug).
28
+ * 放出量の単位となる量子 (px)。有限かつ正であること — それ以外は生成時に throw する
29
+ * (Fail Fast。既定値へ黙って読み替えると呼び出し側のバグを隠す)。
30
+ */
31
+ quantum: number;
32
+ /**
33
+ * Drop the carried residue when the incoming delta's direction opposes it (default: `true`).
34
+ * Without this, scrolling +20px (quantum 24, nothing emitted) and then reversing emits the
35
+ * first reverse quantum "late" — the stale forward residue eats part of the reverse motion.
36
+ * 入力デルタの向きが持ち越し残差と逆のとき残差を捨てる (既定 `true`)。捨てないと、
37
+ * +20px (量子 24 で未放出) の後に反転したとき、古い順方向残差が逆方向の動きを食い、
38
+ * 最初の逆方向量子の発火が「遅れて」体感される。
39
+ */
40
+ resetOnDirectionChange?: boolean;
41
+ };
42
+ /**
43
+ * A quantizer instance. One instance per quantized axis — sharing one across axes mixes residues.
44
+ * 量子化器インスタンス。量子化する軸ごとに 1 つ持つこと — 軸間で共有すると残差が混線する。
45
+ */
46
+ export type ResidualQuantizer = {
47
+ /**
48
+ * Accumulates `delta` and returns the emission: a multiple of the quantum (possibly 0),
49
+ * signed toward the accumulated direction. Non-finite deltas AND deltas whose magnitude
50
+ * exceeds `Number.MAX_SAFE_INTEGER` emit 0 and leave the residue untouched — beyond 2^53
51
+ * the float product stops being an EXACT multiple of the quantum, which is precisely the
52
+ * property a grid-snapping consumer relies on.
53
+ * `delta` を蓄積し、放出量 (量子の倍数。0 のこともある) を蓄積方向の符号で返す。
54
+ * 非有限のデルタ、および絶対値が `Number.MAX_SAFE_INTEGER` を超えるデルタは 0 を返し
55
+ * 残差を汚さない — 2^53 超では浮動小数点積が量子の**正確な**倍数でなくなり、グリッド
56
+ * スナップ消費側が依拠する性質そのものが壊れるため。
57
+ *
58
+ * @param delta - Incoming delta (px) / 入力デルタ (px)
59
+ * @returns Emitted amount (multiple of the quantum) / 放出量 (量子の倍数)
60
+ */
61
+ push: (delta: number) => number;
62
+ /**
63
+ * Returns the carried residue without mutating it (|residue| < quantum).
64
+ * 持ち越し中の残差を変更せずに返す (|残差| < 量子)。
65
+ *
66
+ * @returns Current residue (px) / 現在の残差 (px)
67
+ */
68
+ peekResidue: () => number;
69
+ /**
70
+ * Clears the residue. Call from consumer-side idle timers, on pointer leave, or when the
71
+ * quantized target (row/column layout) changes under the accumulator.
72
+ * 残差を破棄する。消費側のアイドルタイマー・ポインタ離脱・量子化対象 (行/列レイアウト) の
73
+ * 変更時に呼ぶ。
74
+ */
75
+ reset: () => void;
76
+ };
77
+ /**
78
+ * Creates a residual-carrying delta quantizer.
79
+ * 残差持ち越し型のデルタ量子化器を生成する処理。
80
+ *
81
+ * ```ts
82
+ * const quantizer = createResidualQuantizer({ quantum: rowHeight })
83
+ * const handleWheelVertical = (deltaY: number) => {
84
+ * const emitted = quantizer.push(deltaY)
85
+ * if (emitted !== 0) {
86
+ * snapScrollBy(emitted) // 常に量子の倍数
87
+ * }
88
+ * }
89
+ * ```
90
+ *
91
+ * @param options - Quantizer options / 量子化器のオプション
92
+ * @returns A new quantizer instance / 新しい量子化器インスタンス
93
+ * @throws {TypeError} When `quantum` is not a finite positive number / `quantum` が有限の正数でない場合
94
+ */
95
+ export declare const createResidualQuantizer: (options: ResidualQuantizerOptions) => ResidualQuantizer;
96
+ //# sourceMappingURL=residualQuantizer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"residualQuantizer.d.ts","sourceRoot":"","sources":["../src/residualQuantizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;GAGG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACnC;;;;;OAKG;IACH,OAAO,EAAE,MAAM,CAAA;IACf;;;;;;;OAOG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAA;CACnC,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC5B;;;;;;;;;;;;;OAaG;IACH,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAA;IAC/B;;;;;OAKG;IACH,WAAW,EAAE,MAAM,MAAM,CAAA;IACzB;;;;;OAKG;IACH,KAAK,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,uBAAuB,GAAI,SAAS,wBAAwB,KAAG,iBAmC3E,CAAA"}
@@ -1,2 +1,2 @@
1
1
  /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
2
- @layer components{.aqvs-scroll-pane{display:flex;position:relative}.aqvs-scroll-pane[data-self-measured=true]{height:100%}.aqvs-scroll-pane-content{box-sizing:border-box;flex:1;height:100%;position:relative;overflow:hidden}.aqvs-tap-scroll-circle{touch-action:none;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;transition-property:transform;transition-duration:.1s;transition-timing-function:cubic-bezier(0,0,.2,1);display:flex;position:relative}.aqvs-tap-scroll-circle-visual-outer{border-radius:9999px;position:absolute;inset:0}.aqvs-tap-scroll-circle-gradient{background:linear-gradient(to bottom right,#1d4ed899,#60a5fa8c,#bfdbfe66);border:1px solid #fff6;border-radius:9999px;position:absolute;inset:0;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-tap-scroll-circle-inner{border-radius:9999px;position:absolute;inset:18%}.aqvs-sample-visual-pupil{box-sizing:border-box;background-color:#ffffffd9;border:1px solid #ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-highlight{background-color:#fffc;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-rod{background-color:#ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-scrollbar{z-index:50;cursor:default;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none;background-color:#fff;position:relative}.aqvs-scrollbar-horizontal{flex-direction:row;align-items:stretch;display:flex}.aqvs-scrollbar-vertical{flex-direction:column;align-items:stretch;display:flex}.aqvs-scrollbar[data-visible=false],.aqvs-scrollbar-thumb-wrapper[data-visible=false]{opacity:0;pointer-events:none}.aqvs-scrollbar-tap-circle-wrapper{pointer-events:auto;transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);position:absolute}.aqvs-scrollbar-arrow-button{color:#313131;letter-spacing:normal;background-color:#e0e0e0;border:0 solid;justify-content:center;align-items:center;padding:0;font-family:system-ui,sans-serif;font-size:.75rem;font-weight:400;line-height:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:flex}.aqvs-scrollbar-arrow-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scrollbar-arrow-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scrollbar-arrow-button:disabled{cursor:not-allowed;opacity:.5}.aqvs-scrollbar-arrow-button:enabled:hover{background-color:#d4d4d4}.aqvs-scrollbar-track{background-color:#f5f5f5;flex:1;position:relative}.aqvs-scrollbar-overlay{pointer-events:none;position:absolute;inset:0}.aqvs-scrollbar-thumb-wrapper{touch-action:none;position:absolute}.aqvs-scrollbar-thumb{transform-origin:50%;background-color:#7f7f7f;transition:transform 80ms ease-out;position:absolute}.aqvs-scrollbar-thumb[data-thumb-state=disabled]{transition:none;transform:none}.aqvs-scrollbar-thumb[data-thumb-state=hover]{background-color:#5f5f5f}.aqvs-scrollbar-thumb[data-thumb-state=dragging]{background-color:#4f4f4f;transition:transform 60ms ease-out}.aqvs-scrollbar-thumb-horizontal{inset:1.5px 0}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=hover]{top:-.5px;bottom:-.5px;transform:scaleY(1.06)}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=dragging]{top:-.5px;bottom:-.5px;transform:scaleY(1.12)}.aqvs-scrollbar-thumb-vertical{inset:0 1.5px}.aqvs-scrollbar-thumb-vertical[data-thumb-state=hover]{left:-.5px;right:-.5px;transform:scaleX(1.06)}.aqvs-scrollbar-thumb-vertical[data-thumb-state=dragging]{left:-.5px;right:-.5px;transform:scaleX(1.12)}.aqvs-scroll-to-edge-button{pointer-events:auto;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;color:#fff;text-transform:uppercase;letter-spacing:.05em;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background-color:#1f2937cc;border:0 solid;border-radius:9999px;padding:.25rem 3rem;font-family:inherit;font-size:10px;font-weight:500;line-height:1.5;transition-property:transform,background-color;transition-duration:.15s;transform:none;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-scroll-to-edge-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scroll-to-edge-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scroll-to-edge-button:hover{background-color:#374151}.aqvs-scroll-to-edge-button:active{transform:scale(.95)}.aqvs-scroll-to-edge-overlay{pointer-events:none;z-index:10;transition-property:opacity;transition-duration:.5s;position:absolute;inset:0}.aqvs-scroll-to-edge-overlay[data-visible=true]{opacity:1}.aqvs-scroll-to-edge-overlay[data-visible=false]{opacity:0}.aqvs-scroll-to-edge-overlay[data-visible=false] .aqvs-scroll-to-edge-button{pointer-events:none}.aqvs-scroll-to-edge-button-container{justify-content:center;display:flex;position:absolute;left:0;right:0}.aqvs-scroll-to-edge-button-container-top{top:.5rem}.aqvs-scroll-to-edge-button-container-bottom{bottom:.5rem}.aqvs-no-items-container{width:100%;position:absolute;top:0}.aqvs-no-items-text{text-align:center;color:#6b7280}.aqvs-item-container,.aqvs-bottom-inset,.aqvs-items-wrapper{width:100%;position:absolute}}
2
+ @layer components{.aqvs-scroll-pane{display:flex;position:relative}.aqvs-scroll-pane[data-self-measured=true]{height:100%}.aqvs-scroll-pane-content{box-sizing:border-box;flex:1;height:100%;position:relative;overflow:hidden}.aqvs-tap-scroll-circle{touch-action:none;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;transition-property:transform;transition-duration:.1s;transition-timing-function:cubic-bezier(0,0,.2,1);display:flex;position:relative}.aqvs-tap-scroll-circle-visual-outer{border-radius:9999px;position:absolute;inset:0}.aqvs-tap-scroll-circle-gradient{background:linear-gradient(to bottom right,#1d4ed899,#60a5fa8c,#bfdbfe66);border:1px solid #fff6;border-radius:9999px;position:absolute;inset:0;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-tap-scroll-circle-inner{border-radius:9999px;position:absolute;inset:18%}.aqvs-sample-visual-pupil{box-sizing:border-box;background-color:#ffffffd9;border:1px solid #ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-highlight{background-color:#fffc;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-rod{background-color:#ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-scrollbar{z-index:50;cursor:default;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none;background-color:#fff;position:relative}.aqvs-scrollbar-horizontal{flex-direction:row;align-items:stretch;display:flex}.aqvs-scrollbar-vertical{flex-direction:column;align-items:stretch;display:flex}.aqvs-scrollbar[data-visible=false],.aqvs-scrollbar-thumb-wrapper[data-visible=false]{opacity:0;pointer-events:none}.aqvs-scrollbar-tap-circle-wrapper{pointer-events:auto;transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);position:absolute}.aqvs-scrollbar-arrow-button{color:#313131;letter-spacing:normal;background-color:#e0e0e0;border:0 solid;justify-content:center;align-items:center;padding:0;font-family:system-ui,sans-serif;font-size:.75rem;font-weight:400;line-height:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:flex}.aqvs-scrollbar-arrow-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scrollbar-arrow-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scrollbar-arrow-button:disabled{cursor:not-allowed;opacity:.5}.aqvs-scrollbar-arrow-button:enabled:hover{background-color:#d4d4d4}.aqvs-scrollbar-track{background-color:#f5f5f5;flex:1;position:relative}.aqvs-scrollbar-overlay{pointer-events:none;position:absolute;inset:0}.aqvs-scrollbar-thumb-wrapper{touch-action:none;position:absolute}.aqvs-scrollbar-thumb{transform-origin:50%;background-color:#7f7f7f;transition:transform 80ms ease-out;position:absolute}.aqvs-scrollbar-thumb[data-thumb-state=disabled]{transition:none;transform:none}.aqvs-scrollbar-thumb[data-thumb-state=hover]{background-color:#5f5f5f}.aqvs-scrollbar-thumb[data-thumb-state=dragging]{background-color:#4f4f4f;transition:transform 60ms ease-out}.aqvs-scrollbar-thumb-horizontal{inset:1.5px 0}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=hover]{top:-.5px;bottom:-.5px;transform:scaleY(1.06)}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=dragging]{top:-.5px;bottom:-.5px;transform:scaleY(1.12)}.aqvs-scrollbar-thumb-vertical{inset:0 1.5px}.aqvs-scrollbar-thumb-vertical[data-thumb-state=hover]{left:-.5px;right:-.5px;transform:scaleX(1.06)}.aqvs-scrollbar-thumb-vertical[data-thumb-state=dragging]{left:-.5px;right:-.5px;transform:scaleX(1.12)}.aqvs-scroll-to-edge-button{pointer-events:auto;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;color:#fff;text-transform:uppercase;letter-spacing:.05em;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background-color:#1f2937cc;border:0 solid;border-radius:9999px;padding:.25rem 3rem;font-family:inherit;font-size:10px;font-weight:500;line-height:1.5;transition-property:transform,background-color;transition-duration:.15s;transform:none;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-scroll-to-edge-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scroll-to-edge-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scroll-to-edge-button:hover{background-color:#374151}.aqvs-scroll-to-edge-button:active{transform:scale(.95)}.aqvs-scroll-to-edge-overlay{pointer-events:none;z-index:10;transition-property:opacity;transition-duration:.5s;position:absolute;inset:0}.aqvs-scroll-to-edge-overlay[data-visible=true]{opacity:1}.aqvs-scroll-to-edge-overlay[data-visible=false]{opacity:0}.aqvs-scroll-to-edge-overlay[data-visible=false] .aqvs-scroll-to-edge-button{pointer-events:none}.aqvs-scroll-to-edge-button-container{justify-content:center;display:flex;position:absolute;left:0;right:0}.aqvs-scroll-to-edge-button-container-top{top:.5rem}.aqvs-scroll-to-edge-button-container-bottom{bottom:.5rem}.aqvs-no-items-container{width:100%;position:absolute;top:0}.aqvs-no-items-text{text-align:center;color:#6b7280}.aqvs-item-container,.aqvs-bottom-inset,.aqvs-items-wrapper{width:100%;position:absolute}.aqvs-live-region{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}}
@@ -1,3 +1,3 @@
1
1
  /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
2
2
  /*! @aiquants/virtualscroll standalone CSS — 非 Tailwind ホスト専用。ホストの Tailwind ビルドと混在させないこと */
3
- @layer theme,base;@layer components{.aqvs-scroll-pane{display:flex;position:relative}.aqvs-scroll-pane[data-self-measured=true]{height:100%}.aqvs-scroll-pane-content{box-sizing:border-box;flex:1;height:100%;position:relative;overflow:hidden}.aqvs-tap-scroll-circle{touch-action:none;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;transition-property:transform;transition-duration:.1s;transition-timing-function:cubic-bezier(0,0,.2,1);display:flex;position:relative}.aqvs-tap-scroll-circle-visual-outer{border-radius:9999px;position:absolute;inset:0}.aqvs-tap-scroll-circle-gradient{background:linear-gradient(to bottom right,#1d4ed899,#60a5fa8c,#bfdbfe66);border:1px solid #fff6;border-radius:9999px;position:absolute;inset:0;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-tap-scroll-circle-inner{border-radius:9999px;position:absolute;inset:18%}.aqvs-sample-visual-pupil{box-sizing:border-box;background-color:#ffffffd9;border:1px solid #ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-highlight{background-color:#fffc;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-rod{background-color:#ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-scrollbar{z-index:50;cursor:default;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none;background-color:#fff;position:relative}.aqvs-scrollbar-horizontal{flex-direction:row;align-items:stretch;display:flex}.aqvs-scrollbar-vertical{flex-direction:column;align-items:stretch;display:flex}.aqvs-scrollbar[data-visible=false],.aqvs-scrollbar-thumb-wrapper[data-visible=false]{opacity:0;pointer-events:none}.aqvs-scrollbar-tap-circle-wrapper{pointer-events:auto;transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);position:absolute}.aqvs-scrollbar-arrow-button{color:#313131;letter-spacing:normal;background-color:#e0e0e0;border:0 solid;justify-content:center;align-items:center;padding:0;font-family:system-ui,sans-serif;font-size:.75rem;font-weight:400;line-height:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:flex}.aqvs-scrollbar-arrow-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scrollbar-arrow-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scrollbar-arrow-button:disabled{cursor:not-allowed;opacity:.5}.aqvs-scrollbar-arrow-button:enabled:hover{background-color:#d4d4d4}.aqvs-scrollbar-track{background-color:#f5f5f5;flex:1;position:relative}.aqvs-scrollbar-overlay{pointer-events:none;position:absolute;inset:0}.aqvs-scrollbar-thumb-wrapper{touch-action:none;position:absolute}.aqvs-scrollbar-thumb{transform-origin:50%;background-color:#7f7f7f;transition:transform 80ms ease-out;position:absolute}.aqvs-scrollbar-thumb[data-thumb-state=disabled]{transition:none;transform:none}.aqvs-scrollbar-thumb[data-thumb-state=hover]{background-color:#5f5f5f}.aqvs-scrollbar-thumb[data-thumb-state=dragging]{background-color:#4f4f4f;transition:transform 60ms ease-out}.aqvs-scrollbar-thumb-horizontal{inset:1.5px 0}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=hover]{top:-.5px;bottom:-.5px;transform:scaleY(1.06)}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=dragging]{top:-.5px;bottom:-.5px;transform:scaleY(1.12)}.aqvs-scrollbar-thumb-vertical{inset:0 1.5px}.aqvs-scrollbar-thumb-vertical[data-thumb-state=hover]{left:-.5px;right:-.5px;transform:scaleX(1.06)}.aqvs-scrollbar-thumb-vertical[data-thumb-state=dragging]{left:-.5px;right:-.5px;transform:scaleX(1.12)}.aqvs-scroll-to-edge-button{pointer-events:auto;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;color:#fff;text-transform:uppercase;letter-spacing:.05em;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background-color:#1f2937cc;border:0 solid;border-radius:9999px;padding:.25rem 3rem;font-family:inherit;font-size:10px;font-weight:500;line-height:1.5;transition-property:transform,background-color;transition-duration:.15s;transform:none;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-scroll-to-edge-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scroll-to-edge-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scroll-to-edge-button:hover{background-color:#374151}.aqvs-scroll-to-edge-button:active{transform:scale(.95)}.aqvs-scroll-to-edge-overlay{pointer-events:none;z-index:10;transition-property:opacity;transition-duration:.5s;position:absolute;inset:0}.aqvs-scroll-to-edge-overlay[data-visible=true]{opacity:1}.aqvs-scroll-to-edge-overlay[data-visible=false]{opacity:0}.aqvs-scroll-to-edge-overlay[data-visible=false] .aqvs-scroll-to-edge-button{pointer-events:none}.aqvs-scroll-to-edge-button-container{justify-content:center;display:flex;position:absolute;left:0;right:0}.aqvs-scroll-to-edge-button-container-top{top:.5rem}.aqvs-scroll-to-edge-button-container-bottom{bottom:.5rem}.aqvs-no-items-container{width:100%;position:absolute;top:0}.aqvs-no-items-text{text-align:center;color:#6b7280}.aqvs-item-container,.aqvs-bottom-inset,.aqvs-items-wrapper{width:100%;position:absolute}}@layer utilities;
3
+ @layer theme,base;@layer components{.aqvs-scroll-pane{display:flex;position:relative}.aqvs-scroll-pane[data-self-measured=true]{height:100%}.aqvs-scroll-pane-content{box-sizing:border-box;flex:1;height:100%;position:relative;overflow:hidden}.aqvs-tap-scroll-circle{touch-action:none;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;transition-property:transform;transition-duration:.1s;transition-timing-function:cubic-bezier(0,0,.2,1);display:flex;position:relative}.aqvs-tap-scroll-circle-visual-outer{border-radius:9999px;position:absolute;inset:0}.aqvs-tap-scroll-circle-gradient{background:linear-gradient(to bottom right,#1d4ed899,#60a5fa8c,#bfdbfe66);border:1px solid #fff6;border-radius:9999px;position:absolute;inset:0;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-tap-scroll-circle-inner{border-radius:9999px;position:absolute;inset:18%}.aqvs-sample-visual-pupil{box-sizing:border-box;background-color:#ffffffd9;border:1px solid #ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-highlight{background-color:#fffc;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-sample-visual-rod{background-color:#ffffff80;border-radius:9999px;position:absolute;top:50%;left:50%}.aqvs-scrollbar{z-index:50;cursor:default;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none;background-color:#fff;position:relative}.aqvs-scrollbar-horizontal{flex-direction:row;align-items:stretch;display:flex}.aqvs-scrollbar-vertical{flex-direction:column;align-items:stretch;display:flex}.aqvs-scrollbar[data-visible=false],.aqvs-scrollbar-thumb-wrapper[data-visible=false]{opacity:0;pointer-events:none}.aqvs-scrollbar-tap-circle-wrapper{pointer-events:auto;transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);position:absolute}.aqvs-scrollbar-arrow-button{color:#313131;letter-spacing:normal;background-color:#e0e0e0;border:0 solid;justify-content:center;align-items:center;padding:0;font-family:system-ui,sans-serif;font-size:.75rem;font-weight:400;line-height:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:flex}.aqvs-scrollbar-arrow-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scrollbar-arrow-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scrollbar-arrow-button:disabled{cursor:not-allowed;opacity:.5}.aqvs-scrollbar-arrow-button:enabled:hover{background-color:#d4d4d4}.aqvs-scrollbar-track{background-color:#f5f5f5;flex:1;position:relative}.aqvs-scrollbar-overlay{pointer-events:none;position:absolute;inset:0}.aqvs-scrollbar-thumb-wrapper{touch-action:none;position:absolute}.aqvs-scrollbar-thumb{transform-origin:50%;background-color:#7f7f7f;transition:transform 80ms ease-out;position:absolute}.aqvs-scrollbar-thumb[data-thumb-state=disabled]{transition:none;transform:none}.aqvs-scrollbar-thumb[data-thumb-state=hover]{background-color:#5f5f5f}.aqvs-scrollbar-thumb[data-thumb-state=dragging]{background-color:#4f4f4f;transition:transform 60ms ease-out}.aqvs-scrollbar-thumb-horizontal{inset:1.5px 0}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=hover]{top:-.5px;bottom:-.5px;transform:scaleY(1.06)}.aqvs-scrollbar-thumb-horizontal[data-thumb-state=dragging]{top:-.5px;bottom:-.5px;transform:scaleY(1.12)}.aqvs-scrollbar-thumb-vertical{inset:0 1.5px}.aqvs-scrollbar-thumb-vertical[data-thumb-state=hover]{left:-.5px;right:-.5px;transform:scaleX(1.06)}.aqvs-scrollbar-thumb-vertical[data-thumb-state=dragging]{left:-.5px;right:-.5px;transform:scaleX(1.12)}.aqvs-scroll-to-edge-button{pointer-events:auto;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;color:#fff;text-transform:uppercase;letter-spacing:.05em;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background-color:#1f2937cc;border:0 solid;border-radius:9999px;padding:.25rem 3rem;font-family:inherit;font-size:10px;font-weight:500;line-height:1.5;transition-property:transform,background-color;transition-duration:.15s;transform:none;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.aqvs-scroll-to-edge-button:focus{outline-offset:2px;outline:2px solid #0000}.aqvs-scroll-to-edge-button:focus-visible{outline-offset:1px;outline:2px solid #60a5fa}.aqvs-scroll-to-edge-button:hover{background-color:#374151}.aqvs-scroll-to-edge-button:active{transform:scale(.95)}.aqvs-scroll-to-edge-overlay{pointer-events:none;z-index:10;transition-property:opacity;transition-duration:.5s;position:absolute;inset:0}.aqvs-scroll-to-edge-overlay[data-visible=true]{opacity:1}.aqvs-scroll-to-edge-overlay[data-visible=false]{opacity:0}.aqvs-scroll-to-edge-overlay[data-visible=false] .aqvs-scroll-to-edge-button{pointer-events:none}.aqvs-scroll-to-edge-button-container{justify-content:center;display:flex;position:absolute;left:0;right:0}.aqvs-scroll-to-edge-button-container-top{top:.5rem}.aqvs-scroll-to-edge-button-container-bottom{bottom:.5rem}.aqvs-no-items-container{width:100%;position:absolute;top:0}.aqvs-no-items-text{text-align:center;color:#6b7280}.aqvs-item-container,.aqvs-bottom-inset,.aqvs-items-wrapper{width:100%;position:absolute}.aqvs-live-region{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}}@layer utilities;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/virtualscroll",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "High-performance virtual scrolling component for React with variable item heights",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -148,6 +148,34 @@ export type VirtualScrollBehaviorOptions = {
148
148
  */
149
149
  pointerDragInputs?: ScrollPaneProps["pointerDragInputs"]
150
150
  enableKeyboardNavigation?: boolean
151
+ /**
152
+ * When `true`, pressing `Escape` while focus sits on an element INSIDE a row returns focus to
153
+ * the row wrapper (default: `false`).
154
+ * `true` のとき、行の**中**の要素にフォーカスがある状態で `Escape` を押すと行ラッパーへ
155
+ * フォーカスを戻す (既定 `false`)。
156
+ *
157
+ * 行はタブ順に入らない (`tabIndex={-1}`) ため、行内のリンクやウィジェットへ入った後、
158
+ * キーボードだけで行 (縦横矢印ナビゲーションの起点) へ戻る手段が既定では存在しない。
159
+ * このオプションはその復帰口をパッケージ側で提供する。
160
+ *
161
+ * ❗ **既定 OFF なのは `Escape` が最も多重負荷のキーだからである** (モーダルやドロップダウンを
162
+ * 閉じる・IME 確定取消・選択解除)。次の防御で行内ウィジェットの `Escape` を奪わない:
163
+ *
164
+ * - **bubble フェーズで聞く** (矢印キーの capture とは逆)。行内で開いたドロップダウン等が
165
+ * 自分の `Escape` を先に処理でき、`stopPropagation()` すれば本機能には届かない。
166
+ * - `event.defaultPrevented` 済みなら何もしない (preventDefault だけの消費側も尊重)。
167
+ * - IME 合成中 (`isComposing`) は何もしない (確定取消を奪わない)。
168
+ * - **編集可能なターゲット (input / textarea / select / contentEditable) では何もしない。**
169
+ * ネイティブの既定動作 (Blink / WebKit の `<input type="search">` は Escape でクリアされる) は
170
+ * `defaultPrevented` に現れないため、スクリプト先約の検査では守れない。
171
+ * - 行そのものにフォーカスがあるときは何もしない (行上の `Escape` の意味 — 選択解除など — は
172
+ * 消費側の所有物)。
173
+ *
174
+ * 作用時は `preventDefault()` のみで伝播は止めない (矢印キーと同じ契約)。復帰は
175
+ * `focusItemAtIndex` 経由なので、行が画面外なら同時にスクロールで可視化される。
176
+ * `behaviorOptions.enableKeyboardNavigation` が前提 (行ハンドラ自体が付かないため)。
177
+ */
178
+ enableEscapeRowReturn?: boolean
151
179
  wheelSpeedMultiplier?: number
152
180
  inertiaOptions?: ScrollPaneProps["inertiaOptions"]
153
181
  /**
@@ -175,6 +203,50 @@ export type VirtualScrollBehaviorOptions = {
175
203
  resetOnGetItemHeightChange?: boolean
176
204
  }
177
205
 
206
+ /**
207
+ * Range info handed to {@link VirtualScrollLiveRegionOptions.format}.
208
+ * {@link VirtualScrollLiveRegionOptions.format} へ渡す範囲情報。
209
+ */
210
+ export type VirtualScrollLiveRegionRange = {
211
+ /** Index of the first visible item / 可視先頭アイテムのインデックス */
212
+ visibleStartIndex: number
213
+ /** Index of the last visible item / 可視末尾アイテムのインデックス */
214
+ visibleEndIndex: number
215
+ /** Total item count / 総アイテム数 */
216
+ itemCount: number
217
+ }
218
+
219
+ /**
220
+ * Opt-in screen-reader live region announcing the visible range (see the `liveRegion` prop).
221
+ * 可視範囲を読み上げる opt-in のスクリーンリーダー用ライブリージョン (`liveRegion` prop 参照)。
222
+ */
223
+ export type VirtualScrollLiveRegionOptions = {
224
+ /**
225
+ * Formats the announcement text. Called after the visible range settles; returning the same
226
+ * string as last time leaves the DOM untouched (no re-announcement), returning `""` clears
227
+ * the region. The package ships NO built-in strings — wording and language are the
228
+ * consumer's (a generic package must not hardcode a locale).
229
+ * 読み上げ文言を組み立てる。可視範囲が静定した後に呼ばれ、前回と同じ文字列なら DOM を
230
+ * 触らない (再読み上げしない)。`""` を返すとリージョンを空にする。パッケージは文言を
231
+ * 一切内蔵しない — 言語と文面は消費側の所有物 (汎用パッケージはロケールをハードコード
232
+ * できない)。
233
+ *
234
+ * @param range - The settled visible range / 静定した可視範囲
235
+ * @returns Announcement text / 読み上げ文言
236
+ */
237
+ format: (range: VirtualScrollLiveRegionRange) => string
238
+ /**
239
+ * Debounce (ms) after the last range change before announcing (default: 400). Must be a
240
+ * finite number ≥ 0 — anything else logs a warning and disables announcements (no silent
241
+ * substitution). Announcing every wheel tick makes the reader chatter over the content;
242
+ * announcing too late reads a stale position. 0 announces on the next effect flush.
243
+ * 最後の範囲変化から読み上げまでのデバウンス (ms、既定 400)。有限かつ 0 以上であること —
244
+ * それ以外は警告して読み上げを無効化する (黙って既定へ読み替えない)。ホイール 1 ノッチ
245
+ * ごとに読むと内容の読み上げに被り、遅すぎると古い位置を読む。0 は次の effect で即時。
246
+ */
247
+ debounceMs?: number
248
+ }
249
+
178
250
  /**
179
251
  * Props for the VirtualScroll component.
180
252
  *
@@ -220,6 +292,24 @@ export type VirtualScrollProps<T> = {
220
292
  testId?: string
221
293
  onScroll?: (scrollPosition: number, totalHeight: number) => void
222
294
  onRangeChange?: (range: VirtualScrollRange) => void
295
+ /**
296
+ * Opt-in `aria-live` region announcing the visible range to assistive technology (default:
297
+ * none rendered). Virtualization removes off-screen rows from the DOM, so a screen-reader
298
+ * user scrolling the list otherwise gets no feedback about where they are. Provide a
299
+ * {@link VirtualScrollLiveRegionOptions.format} to opt in; the region is a visually hidden
300
+ * `role="status"` element (polite, atomic) rendered as a sibling of the pane, updated after
301
+ * the range settles ({@link VirtualScrollLiveRegionOptions.debounceMs}).
302
+ *
303
+ * 可視範囲を支援技術へ読み上げる opt-in の `aria-live` リージョン (既定: 描画しない)。
304
+ * 仮想化は画面外の行を DOM から取り除くため、スクリーンリーダーの利用者はスクロールしても
305
+ * 現在位置の手掛かりを得られない。{@link VirtualScrollLiveRegionOptions.format} を渡すと、
306
+ * 視覚的に隠した `role="status"` 要素 (polite・atomic) をペインの兄弟として描画し、範囲が
307
+ * 静定した後に更新する。
308
+ *
309
+ * ❗ **ホスト側に既存のライブリージョンがある場合は併用しないこと** (二重読み上げになる)。
310
+ * その場合は `onRangeChange` から自前のリージョンを更新する従来手段を使う。
311
+ */
312
+ liveRegion?: VirtualScrollLiveRegionOptions
223
313
  background?: ReactNode
224
314
  children: (item: T, index: number) => ReactNode
225
315
  initialScrollIndex?: number
@@ -822,6 +912,15 @@ type VirtualScrollItemProps<T> = {
822
912
  clipItemHeight: boolean
823
913
  enableKeyboardNavigation: boolean
824
914
  onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>, index: number) => void
915
+ /**
916
+ * Bubble-phase Escape handler for the row-return feature (undefined when disabled).
917
+ * 行復帰機能用の bubble フェーズ Escape ハンドラ (機能無効時は undefined)。
918
+ *
919
+ * ❗ 矢印キー (`onKeyDown` = capture) と**逆のフェーズ**なのが本質: capture で聞くと行内で
920
+ * 開いたドロップダウン等より先に Escape を奪ってしまう。bubble なら行内ウィジェットが
921
+ * `stopPropagation()` / `preventDefault()` で先約を主張できる。
922
+ */
923
+ onEscapeKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>, index: number) => void
825
924
  onFocus: (index: number) => void
826
925
  registerItemRef: (index: number, node: HTMLDivElement | null) => void
827
926
  }
@@ -835,15 +934,20 @@ type VirtualScrollItemProps<T> = {
835
934
  * ユーザーが提供したアイテムコンポーネントを、配置スタイルとイベントハンドラでラップします。
836
935
  * Props が変更されていない場合の不要な再レンダリングを防ぐためにメモ化されています。
837
936
  */
838
- const VirtualScrollItem = React.memo(<T,>({ index, top, height, item, children, clipItemHeight, enableKeyboardNavigation, onKeyDown, onFocus, registerItemRef }: VirtualScrollItemProps<T>) => {
937
+ const VirtualScrollItem = React.memo(<T,>({ index, top, height, item, children, clipItemHeight, enableKeyboardNavigation, onKeyDown, onEscapeKeyDown, onFocus, registerItemRef }: VirtualScrollItemProps<T>) => {
839
938
  const handleRef = useCallback((node: HTMLDivElement | null) => registerItemRef(index, node), [index, registerItemRef])
840
939
  const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => onKeyDown(e, index), [index, onKeyDown])
940
+ const handleEscapeKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => onEscapeKeyDown?.(e, index), [index, onEscapeKeyDown])
841
941
  const handleFocus = useCallback(() => onFocus(index), [index, onFocus])
842
942
  const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
843
943
  e.currentTarget.focus({ preventScroll: true })
844
944
  }, [])
845
945
 
846
946
  return (
947
+ // 行ラッパーの role は消費側の複合ウィジェット契約 (listbox / tree / grid — README「Composite widget
948
+ // roles」) の所有物で、パッケージが既定 role を課すと衝突する。キー/フォーカス結線は
949
+ // enableKeyboardNavigation の行ナビゲーション機構そのもの。
950
+ // biome-ignore lint/a11y/noStaticElementInteractions: role は消費側の複合ウィジェット契約の所有物 (上記)
847
951
  <div
848
952
  ref={handleRef}
849
953
  data-index={index}
@@ -857,6 +961,7 @@ const VirtualScrollItem = React.memo(<T,>({ index, top, height, item, children,
857
961
  tabIndex={enableKeyboardNavigation ? -1 : undefined}
858
962
  onPointerDown={enableKeyboardNavigation ? handlePointerDown : undefined}
859
963
  onKeyDownCapture={enableKeyboardNavigation ? handleKeyDown : undefined}
964
+ onKeyDown={enableKeyboardNavigation && onEscapeKeyDown ? handleEscapeKeyDown : undefined}
860
965
  onFocusCapture={enableKeyboardNavigation ? handleFocus : undefined}>
861
966
  {children(item, index)}
862
967
  </div>
@@ -894,12 +999,13 @@ const VirtualScrollInner = <T,>(
894
999
  horizontalKeyInputs,
895
1000
  horizontalKeyStep = DEFAULT_HORIZONTAL_KEY_STEP,
896
1001
  contentProps,
1002
+ liveRegion,
897
1003
  }: VirtualScrollProps<T>,
898
1004
  ref: React.Ref<VirtualScrollHandle>,
899
1005
  ) => {
900
1006
  const { width: scrollBarWidth, enableThumbDrag, enableTrackClick, enableArrowButtons, enableScrollToTopBottomButtons, renderThumbOverlay, tapScrollCircleOptions } = scrollBarOptions ?? {}
901
1007
 
902
- const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
1008
+ const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, enableEscapeRowReturn = false, wheelSpeedMultiplier, inertiaOptions, overscrollBehavior, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
903
1009
 
904
1010
  // viewportSize 未指定のときは ScrollPane が自分の帯を計測し、その値をこのコールバックで通知する。
905
1011
  // 描画枚数 (computeRenderingRanges) とアライン計算 (scrollToIndex / ドリフト補正) はこの解決済み値を使う。
@@ -1956,6 +2062,47 @@ const VirtualScrollInner = <T,>(
1956
2062
  [enableKeyboardNavigation, itemCount, focusItemAtIndex, horizontalKeyInputs, horizontalKeyStep],
1957
2063
  )
1958
2064
 
2065
+ const handleItemEscapeReturn = useCallback(
2066
+ /**
2067
+ * Returns focus from an element inside the row back to the row wrapper on Escape.
2068
+ * 行内要素の Escape で行ラッパーへフォーカスを戻す処理。
2069
+ *
2070
+ * @param event - Keyboard event (bubble phase on the row wrapper) / 行ラッパー bubble フェーズのキーイベント
2071
+ * @param index - Row index / 行インデックス
2072
+ */
2073
+ (event: React.KeyboardEvent<HTMLDivElement>, index: number) => {
2074
+ if (event.key !== "Escape") {
2075
+ return
2076
+ }
2077
+ // 行内ウィジェット (ドロップダウン等) が先に消費した Escape は尊重する。
2078
+ // bubble フェーズなのでウィジェット側の preventDefault はここで必ず観測できる
2079
+ // (stopPropagation されていればそもそもこのハンドラに届かない)
2080
+ if (event.defaultPrevented) {
2081
+ return
2082
+ }
2083
+ // IME 合成中の Escape は確定取消であり、フォーカス移動で奪ってはならない
2084
+ if (event.nativeEvent.isComposing) {
2085
+ return
2086
+ }
2087
+ // 行そのものにフォーカスがあるときの Escape の意味 (選択解除など) は消費側の所有物
2088
+ if (event.target === event.currentTarget) {
2089
+ return
2090
+ }
2091
+ // ❗ 編集可能なターゲット (input / textarea / select / contentEditable) の Escape は奪わない。
2092
+ // ネイティブの既定動作 (例: Blink/WebKit は <input type="search"> を Escape でクリアする) は
2093
+ // defaultPrevented に**現れない**ため、上のスクリプト先約の検査では守れない — ここで
2094
+ // preventDefault すると検索欄のクリアを黙殺したうえ編集途中のフォーカスまで奪う (実 Chromium で実証)
2095
+ const target = event.target as HTMLElement
2096
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target.isContentEditable) {
2097
+ return
2098
+ }
2099
+ // 消費の作法は矢印キーと同じ: preventDefault のみで伝播は止めない
2100
+ event.preventDefault()
2101
+ focusItemAtIndex(index)
2102
+ },
2103
+ [focusItemAtIndex],
2104
+ )
2105
+
1959
2106
  const handleItemFocus = useCallback(
1960
2107
  (index: number) => {
1961
2108
  if (!enableKeyboardNavigation) {
@@ -2157,6 +2304,7 @@ const VirtualScrollInner = <T,>(
2157
2304
  clipItemHeight={clipItemHeight}
2158
2305
  enableKeyboardNavigation={enableKeyboardNavigation}
2159
2306
  onKeyDown={handleItemKeyDown}
2307
+ onEscapeKeyDown={enableEscapeRowReturn ? handleItemEscapeReturn : undefined}
2160
2308
  onFocus={handleItemFocus}
2161
2309
  registerItemRef={registerItemRef}>
2162
2310
  {children}
@@ -2220,6 +2368,8 @@ const VirtualScrollInner = <T,>(
2220
2368
  getItemHeight,
2221
2369
  handleItemFocus,
2222
2370
  handleItemKeyDown,
2371
+ enableEscapeRowReturn,
2372
+ handleItemEscapeReturn,
2223
2373
  issueCompensationScroll,
2224
2374
  registerItemRef,
2225
2375
  renderingEndIndex,
@@ -2311,6 +2461,53 @@ const VirtualScrollInner = <T,>(
2311
2461
  currentRangeRef.current = currentRange
2312
2462
  }, [currentRange])
2313
2463
 
2464
+ // ライブリージョンの読み上げ文言。liveRegion 未指定なら常に空のまま (リージョン自体を描画しない)
2465
+ const [liveAnnouncement, setLiveAnnouncement] = useState("")
2466
+ // format は消費側がインラインで渡すのが普通なので ref 越しに読む (identity 変化でデバウンスを
2467
+ // 巻き戻さない — 巻き戻すと親の毎レンダーでタイマーが再スタートし、永遠に読み上げない)
2468
+ const liveRegionRef = useRef(liveRegion)
2469
+ useLayoutEffect(() => {
2470
+ liveRegionRef.current = liveRegion
2471
+ }, [liveRegion])
2472
+ const liveRegionDebounceMs = liveRegion?.debounceMs ?? 400
2473
+ const isLiveRegionDebounceValid = Number.isFinite(liveRegionDebounceMs) && liveRegionDebounceMs >= 0
2474
+ const hasLiveRegion = liveRegion !== undefined
2475
+
2476
+ useEffect(() => {
2477
+ // 目的: 不正な debounceMs を黙って既定へ読み替えず、機能を無効化したうえで 1 回だけ警告する。
2478
+ // 依存関係: hasLiveRegion, isLiveRegionDebounceValid, liveRegionDebounceMs
2479
+ if (hasLiveRegion && !isLiveRegionDebounceValid) {
2480
+ Logger.warn(`[VirtualScroll] liveRegion.debounceMs must be a finite number >= 0, received ${liveRegionDebounceMs}. Announcements are disabled.`)
2481
+ }
2482
+ }, [hasLiveRegion, isLiveRegionDebounceValid, liveRegionDebounceMs])
2483
+
2484
+ useEffect(() => {
2485
+ // 目的: 可視範囲の静定後にライブリージョンの文言を更新する (trailing デバウンス)。
2486
+ // 範囲が動くたびにこの effect が張り直され、タイマーが再スタートする = 最後の変化から
2487
+ // debounceMs 後に 1 回だけ発火する。
2488
+ // 依存関係: hasLiveRegion, isLiveRegionDebounceValid, liveRegionDebounceMs,
2489
+ // visibleStartIndex, visibleEndIndex, itemCount
2490
+ if (!(hasLiveRegion && isLiveRegionDebounceValid)) {
2491
+ // ❗ 無効化時は残留文言を消す。消さないと、prop を外して再度付けたとき (実行時トグル) に
2492
+ // 古い範囲の文言が role="status" 要素ごと同期的に再挿入され、新規挿入されたライブ
2493
+ // リージョンを内容ごと読み上げる AT が**過去の位置**を読む。同値なら React が bail out
2494
+ // するため、通常レンダーでのコストは無い
2495
+ setLiveAnnouncement("")
2496
+ return
2497
+ }
2498
+ const timerId = setTimeout(() => {
2499
+ const format = liveRegionRef.current?.format
2500
+ if (!format) {
2501
+ return
2502
+ }
2503
+ const text = format({ visibleStartIndex, visibleEndIndex, itemCount })
2504
+ // 同一文言の再 set は React 自身が bail out する (Object.is 一致で再レンダーしない) ため、
2505
+ // ここで比較ガードを持つ必要は無い — 持っても等価変異になり検証不能な飾りになるだけ
2506
+ setLiveAnnouncement(text)
2507
+ }, liveRegionDebounceMs)
2508
+ return () => clearTimeout(timerId)
2509
+ }, [hasLiveRegion, isLiveRegionDebounceValid, liveRegionDebounceMs, visibleStartIndex, visibleEndIndex, itemCount])
2510
+
2314
2511
  useImperativeHandle(
2315
2512
  ref,
2316
2513
  () => ({
@@ -2338,7 +2535,8 @@ const VirtualScrollInner = <T,>(
2338
2535
 
2339
2536
  const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom
2340
2537
 
2341
- return (
2538
+ // ライブリージョン無効時はペインをそのまま根要素として返す (既存消費者の DOM 形状を変えない)
2539
+ const pane = (
2342
2540
  <ScrollPane
2343
2541
  ref={scrollPaneRef}
2344
2542
  contentSize={totalContentHeight}
@@ -2370,6 +2568,21 @@ const VirtualScrollInner = <T,>(
2370
2568
  {renderVisibleItems}
2371
2569
  </ScrollPane>
2372
2570
  )
2571
+
2572
+ if (!hasLiveRegion) {
2573
+ return pane
2574
+ }
2575
+ return (
2576
+ <>
2577
+ {pane}
2578
+ {/* ペインの**兄弟**として描画する: ペイン内部 (スクロール座標系) に置くと transform 配下の
2579
+ aria-live を無視する AT があり、ペインの子構成 (行 / 背景 / オーバーレイ) の契約も汚す。
2580
+ 視覚的には .aqvs-live-region (絶対配置 1px クリップ) で完全に隠れ、レイアウトに影響しない */}
2581
+ <div className="aqvs-live-region" role="status" aria-live="polite" aria-atomic="true" data-aqvs-live-region="">
2582
+ {liveAnnouncement}
2583
+ </div>
2584
+ </>
2585
+ )
2373
2586
  }
2374
2587
 
2375
2588
  /**
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * 可変なアイテム高さに対応したReact用の高性能仮想スクロールコンポーネント。
6
6
  */
7
7
 
8
+ export { createResidualQuantizer, type ResidualQuantizer, type ResidualQuantizerOptions } from "./residualQuantizer.ts"
8
9
  export { computeAutoTapScrollMaxSpeedMultiplier, computeTapScrollSpeed, ScrollBar, type ScrollBarProps, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_SPEED_DEFAULTS, type TapScrollSpeedInput } from "./ScrollBar.tsx"
9
10
  export { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneInertiaOptions, type ScrollPaneProps } from "./ScrollPane.tsx"
10
11
  export type { TapScrollCircleRenderProps } from "./TapScrollCircle.tsx"
@@ -18,6 +19,8 @@ export {
18
19
  VirtualScroll,
19
20
  type VirtualScrollBehaviorOptions,
20
21
  type VirtualScrollHandle,
22
+ type VirtualScrollLiveRegionOptions,
23
+ type VirtualScrollLiveRegionRange,
21
24
  type VirtualScrollProps,
22
25
  type VirtualScrollRange,
23
26
  type VirtualScrollScrollBarOptions,