@aiquants/spreadsheet 0.11.0 → 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/spreadsheet",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Excel-grade spreadsheet grid for React: multi-cell selection, TSV/HTML clipboard, in-place editing on top of @aiquants/virtualscroll",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -34,12 +34,12 @@
34
34
  "LICENSE.md"
35
35
  ],
36
36
  "peerDependencies": {
37
- "@aiquants/virtualscroll": "^3.6.1",
37
+ "@aiquants/virtualscroll": "^3.7.0",
38
38
  "react": "^19.2.7",
39
39
  "react-dom": "^19.2.7"
40
40
  },
41
41
  "devDependencies": {
42
- "@aiquants/virtualscroll": "3.6.1",
42
+ "@aiquants/virtualscroll": "3.7.0",
43
43
  "@playwright/test": "^1.59.1",
44
44
  "@tailwindcss/cli": "^4.1.18",
45
45
  "@testing-library/jest-dom": "^6.9.1",
@@ -27,6 +27,10 @@
27
27
  * clipboard / keyboard (executeCommandRef / openPopoverRef / hideNoteTipRef / pasteRef /
28
28
  * pendingPasteOptionsRef / popover anchors).
29
29
  *
30
+ * UI chrome locale: the `locale` prop is resolved once (`resolveVirtualScrollLocale` — undefined ⇒
31
+ * "en", unknown ⇒ RangeError at render) and drives the label catalog, the built-in validation
32
+ * messages and — through a value-stable engine subset — the grid scrollbars.
33
+ *
30
34
  * v0.8 (計画 §4.2.13 — フック順は設計 v5): 軸モデル → viewStore → ズーム → オートスクローラー
31
35
  * 6 台 (6 台目は参照ドラッグ用) → 選択エンジン → ヘッダー機械 ×2 → ヘッダーリサイズ → fill →
32
36
  * 参照ドラッグ (usePointDrag) → 合成ドラッグ面 (isDraggingAny / abortDragAny / "point" 込みの
@@ -37,9 +41,13 @@
37
41
  * ポインティングの ref 辺 (editor → engine / ヘッダー / 参照ドラッグ / body / コーナー / ズーム死空間)、
38
42
  * コマンド / ポップオーバー / クリップボード / キーボード間の ref 辺 (executeCommandRef /
39
43
  * openPopoverRef / hideNoteTipRef / pasteRef / pendingPasteOptionsRef / ポップオーバーアンカー) を所有する。
44
+ *
45
+ * UI クロームロケール: `locale` prop を 1 回だけ解決し (`resolveVirtualScrollLocale` — undefined は
46
+ * "en"、未知は render 中 RangeError)、ラベルカタログ・組み込み検証メッセージ・値安定なエンジン部分集合
47
+ * 経由のグリッドスクロールバーを駆動する。
40
48
  */
41
49
 
42
- import type { ScrollBarTapCircleOptions, VirtualGridHandle, VirtualGridRange } from "@aiquants/virtualscroll"
50
+ import { resolveVirtualScrollLocale, type ScrollBarTapCircleOptions, VIRTUAL_SCROLL_LABEL_KEYS, type VirtualGridHandle, type VirtualGridRange, type VirtualScrollLabels } from "@aiquants/virtualscroll"
43
51
  import type { ProfilerOnRenderCallback } from "react"
44
52
  import { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"
45
53
  import type { ClipChannel } from "./clipboard/clipPayload.ts"
@@ -67,13 +75,13 @@ import { type CellEdit, type EditContext, type EditRejection, type EditSource, i
67
75
  import { type DataValidationRule, type ValidationVerdict, validateCellText } from "./model/dataValidation.ts"
68
76
  import type { ReferenceAxis } from "./model/formulaRefs.ts"
69
77
  import { colToViewportX, rowToViewportY } from "./model/frozenPartition.ts"
70
- import { resolveLabels, type SpreadsheetLabelOverrides } from "./model/labels.ts"
78
+ import { pickEngineLabels, resolveLabels, type SpreadsheetLabelOverrides, type SpreadsheetLocale } from "./model/labels.ts"
71
79
  import { createViewStore, type ViewStore } from "./model/viewStore.ts"
72
80
  import { DEFAULT_HIGHLIGHT_COLORS, type DragFrameMode, type HighlightColors, type HighlightState, type ResizeGuide, SelectionOverlay } from "./overlay/SelectionOverlay.tsx"
73
81
  import { useFillHandle } from "./overlay/useFillHandle.ts"
74
82
  import { PopoverLayer } from "./popover/PopoverLayer.tsx"
75
83
  import { type PopoverAnchorState, type PopoverContext, type PopoverController, type PopoverKind, type PopoverOpenOptions, type PopoverOverrides, usePopovers } from "./popover/usePopovers.ts"
76
- import { reduceActiveCycle, reduceSelectionExtend, reduceSelectionPress, type SelectionState, selectionStateEquals } from "./selection/selectionState.ts"
84
+ import { clampSelectionToGrid, reduceActiveCycle, reduceSelectionExtend, reduceSelectionPress, type SelectionState, selectionStateEquals } from "./selection/selectionState.ts"
77
85
  import { useAutoScroller } from "./selection/useAutoScroller.ts"
78
86
  import { type PointDragOwner, usePointDrag } from "./selection/usePointDrag.ts"
79
87
  import { useSelectionEngine } from "./selection/useSelectionEngine.ts"
@@ -220,16 +228,27 @@ export type SpreadsheetProps = {
220
228
  /**
221
229
  * Row-axis overscan pass-through (the VirtualGrid `overscanRows` knob; LIVE prop): rows
222
230
  * rendered beyond each vertical viewport edge. This slack is the only thing absorbing the
223
- * engine's one-frame rendering-window lag during fast scrolling (the blank-band remedy —
224
- * the slack in px is overscan × row height per edge). Undefined ⇒ the engine default (3).
225
- * The engine clamps invalid values silently, so THIS surface owns the fail-fast: a
226
- * non-integer or negative value throws a RangeError at render. Cost multiplies with the
227
- * column axis (bounded by the engine cell cap).
231
+ * engine's one-frame rendering-window lag during fast scrolling (the blank-band remedy).
232
+ * Overscan counts ROWS — hidden 0-height rows consume slots too — so the px slack per edge
233
+ * is overscan × row height only where rows are uniform and visible. Undefined ⇒ the engine
234
+ * default (3). The engine does not validate overscan (a negative value silently shrinks the
235
+ * rendering window), so THIS surface owns the fail-fast: a non-integer or negative value
236
+ * throws a RangeError at render. Cost multiplies with the column axis, and the engine
237
+ * TRUNCATES rather than bounds: a window beyond its per-axis row budget or its total cell
238
+ * cap keeps the LEADING side and drops the trailing side — the row-budget case has no
239
+ * callback (the engine only logs a warning), the cell-cap case (columns dropped) reports
240
+ * through `onRenderTruncated`. Values of
241
+ * hundreds of rows per side approach that budget on tall viewports; keep overscan to the
242
+ * slack you need.
228
243
  * 行軸 overscan の素通し (VirtualGrid `overscanRows`。ライブ prop): 縦 viewport 端の外側へ
229
- * 描画する行数。高速スクロール時の描画窓 1 フレーム遅れを吸収する唯一の余裕 (白帯対策 —
230
- * 余裕 px は片端あたり overscan × 行高)。undefined はエンジン既定 (3)。エンジンは不正値を
231
- * 無言クランプするため fail-fast は本層所有 — 非整数・負値は render 時 RangeError。費用は
232
- * 列軸との積で増幅 (エンジンのセル上限で有界)。
244
+ * 描画する行数。高速スクロール時の描画窓 1 フレーム遅れを吸収する唯一の余裕 (白帯対策)。
245
+ * overscan は**行数**で数える (非表示の 0px 行も枠を消費) ため、片端の余裕 px が overscan ×
246
+ * 行高になるのは行が一様に可視のときだけ。undefined はエンジン既定 (3)。エンジンは overscan を
247
+ * 検証しない (負値は描画窓を無言で縮める) ため fail-fast は本層所有 — 非整数・負値は render 時
248
+ * RangeError。費用は列軸との積で増幅し、エンジンは有界化でなく**打切り**で応じる: 軸毎の行予算
249
+ * や総セル上限を超えた窓は先頭側を残し末尾側を落とす — 行予算超過はコールバック無し (エンジンは
250
+ * warn ログのみ)、セル上限超過 (列の脱落) は `onRenderTruncated` で通知。片側数百行の値は高い viewport で予算に迫るため、必要な
251
+ * 余裕にとどめる。
233
252
  */
234
253
  overscanRows?: number
235
254
  /**
@@ -246,18 +265,52 @@ export type SpreadsheetProps = {
246
265
  * translates only its row wrapper, so this layer does NOT follow the scroll offset —
247
266
  * periodic row ruling placed here slides against the real rows while scrolling. Suited to
248
267
  * a static texture that reads as "content still arriving" in the overscan-exhausted band,
249
- * not to row-aligned gridlines.
268
+ * not to row-aligned gridlines. The layer spans the full width of the scroll rows — the
269
+ * frozen-COLUMN region included (frozen columns live inside the scroll rows) — but not the
270
+ * frozen row bands. Identity-stable contract (same as `renderCell`): an inline element here
271
+ * pierces the GridBody memo boundary on every host re-render (the engine's render bodies
272
+ * re-run; cells stay memoized) — hoist the node or `useMemo` it.
250
273
  * スクロール帯行の背後・エンジンコンテンツ要素内に描画する背景レイヤー (VirtualGrid
251
274
  * `background` の素通し。ライブ prop)。viewport 固定: エンジンが translate するのは行
252
275
  * ラッパーだけで、この層はスクロール量に追従しない — 周期的な行罫線を置くとスクロール中に
253
276
  * 実際の行へ対して滑る。overscan が尽きた帯で「まだ到着中」と読める静的テクスチャ向きで、
254
- * 行整合の罫線には不向き。
277
+ * 行整合の罫線には不向き。層はスクロール行の全幅 — 凍結**列**域を含む (凍結列はスクロール行の
278
+ * 内側に居る) — に広がり、凍結行帯には広がらない。identity 安定契約 (`renderCell` と同じ):
279
+ * インライン要素はホスト再レンダー毎に GridBody の memo 境界を貫通する (エンジンの render 本体が
280
+ * 再実行、セルは memo 維持) — ノードは hoist するか `useMemo` で保つ。
255
281
  */
256
282
  background?: ReactNode
283
+ /**
284
+ * Rendering-truncation signal pass-through (the VirtualGrid `onRenderTruncated` callback):
285
+ * fires when the engine drops trailing columns to stay under its total cell cap
286
+ * (`MAX_RENDERED_CELLS`) — the cells actually rendered and the columns dropped. The engine's
287
+ * doctrine is that truncation is never silent; an oversized `overscanCols` × row span is the
288
+ * typical cause. Undefined ⇒ no callback (engine default).
289
+ * 描画打切り通知の素通し (VirtualGrid `onRenderTruncated`): エンジンが総セル上限
290
+ * (`MAX_RENDERED_CELLS`) を守るため末尾列を落としたときに、実描画セル数と脱落列数で発火する。
291
+ * 「打切りは無言にしない」がエンジン教義で、過大な `overscanCols` × 行スパンが典型原因。
292
+ * undefined = 通知なし (エンジン既定)。
293
+ */
294
+ onRenderTruncated?: (info: { renderedCells: number; droppedCols: number }) => void
257
295
  /**
258
296
  * Identity-stable contract (same as adapter): an inline arrow here defeats the CellView memo
259
297
  * and turns every host re-render into a full visible re-slice (~800 cells at 16k columns).
260
- * identity 安定契約 — インライン関数は memo を無効化し毎レンダー全再スライスになる。
298
+ * Per-cell opt-out (v0.12): returning `undefined` from one cell lands that cell on the
299
+ * package default rendering (value-format engine included) — "custom where needed, default
300
+ * elsewhere" without wrapper elements. ONLY `undefined` opts out — `null`, `false` (the
301
+ * `cond && <X/>` idiom) and every other ReactNode are custom output, i.e. an empty custom
302
+ * cell. `ctx.text` is the RAW canonical string; `ctx.displayText` (v0.12) is the value-format
303
+ * display — the exact string the default rendering, the AT announcement, the HTML clipboard
304
+ * and autofit share, so a custom cell that renders it can never drift from what gets copied.
305
+ * Autofit stays a whole-grid no-op while `renderCell` is set (custom DOM width is unknowable).
306
+ * identity 安定契約 — インライン関数は memo を無効化し毎レンダー全再スライスになる。セル単位
307
+ * オプトアウト (v0.12): `undefined` を返したセルはパッケージ既定描画 (値書式エンジン込み) へ
308
+ * 降りる — 「要る列だけカスタム、残りは既定」を器なしで構成できる。オプトアウトは
309
+ * `undefined` **だけ** — `null` / `false` (`cond && <X/>` 慣用) / 他の ReactNode はカスタム
310
+ * 出力 (= 空のカスタムセル)。`ctx.text` は素の正準文字列、`ctx.displayText` (v0.12) は値書式の
311
+ * 表示文字列 — 既定描画・AT 読み上げ・HTML クリップボード・autofit と同一なので、これを描く
312
+ * カスタムセルはコピー結果と食い違わない。autofit は `renderCell` 指定中はグリッド全体
313
+ * no-op のまま (カスタム DOM の幅は測れない)。
261
314
  */
262
315
  renderCell?: (ctx: RenderCellContext) => ReactNode
263
316
  /**
@@ -265,11 +318,16 @@ export type SpreadsheetProps = {
265
318
  * paste replace, Ctrl+A, ...). Never during a drag (state frozen — one fire at pointerup),
266
319
  * with ONE documented in-drag firing point: the header subtract reservation converts to a
267
320
  * real press on the first movement, firing the deferred press replace mid-drag (total fire
268
- * count matches v0.1 — conversion 1 + up 1). Never for the initial A1 mount. Independent
269
- * of the live region's 200ms debounce (firing point: the §6.9-1 setSelection wrapper).
321
+ * count matches v0.1 — conversion 1 + up 1). Never for the initial A1 mount. ONE host-driven
322
+ * fire point outside gestures (ADR-33): a same-adapter rowCount / colCount shrink that moved
323
+ * the selection fires once from a layout effect of that commit — not a user gesture.
324
+ * Independent of the live region's 200ms debounce (firing point: the §6.9-1 setSelection
325
+ * wrapper).
270
326
  * 確定 SelectionState が変わるたびに発火。ドラッグ中と初期マウントでは発火しない。明文の
271
327
  * ドラッグ中発火点が 1 つ: ヘッダー減算予約の実 press 変換 (初動 move) は遅延 press 置換を
272
- * ドラッグ中に発火する (発火総数は v0.1 と同一 — 変換 1 + up 1)。
328
+ * ドラッグ中に発火する (発火総数は v0.1 と同一 — 変換 1 + up 1)。ジェスチャ外のホスト起点発火点が
329
+ * 1 つ (ADR-33): 同一 adapter の rowCount / colCount 縮小で選択が動いたとき、その commit の
330
+ * layout effect から 1 回発火する (ユーザー操作ではない)。
273
331
  */
274
332
  onSelectionChange?: (selection: SelectionState) => void
275
333
  /** Consumer context menu (§6.4.3-4 / §6.6.1 Shift+F10); keyboard origin carries the active cell viewport rect. / consumer コンテキストメニュー。 */
@@ -362,9 +420,11 @@ export type SpreadsheetProps = {
362
420
  enableResizeUI?: boolean
363
421
  /**
364
422
  * AT announcement text for the active cell; omit for silence (no built-in strings). `text`
365
- * is the RAW canonical getCellText; `displayText` is what a sighted user sees (the
423
+ * is the RAW canonical getCellText; `displayText` is the value-format display string (the
366
424
  * getCellFormat-formatted string — equals `text` when the cell has no format), so AT can
367
- * hear "87.0%" for a cell whose raw value is "0.87". `readOnly` (v0.10) is
425
+ * hear "87.0%" for a cell whose raw value is "0.87". It is the same string a custom
426
+ * `renderCell` cell receives as `ctx.displayText` (v0.12) — what a sighted user sees unless
427
+ * the host deliberately renders something else. `readOnly` (v0.10) is
368
428
  * declaration-gated three-valued: the key is ABSENT when the adapter does not declare
369
429
  * `isCellReadOnly` (payload byte-identical to v0.9), `false` = declared editable, `true` =
370
430
  * declared read-only — the navigation-time read-only channel (DOM focus stays on the sink,
@@ -385,14 +445,30 @@ export type SpreadsheetProps = {
385
445
  * コマンドの AT 通知 (opt-in、v0.8 §1.9)。`state` はトグル後状態 (非トグルは null)。live region へ。
386
446
  */
387
447
  formatCommandAnnouncement?: (ctx: { command: SpreadsheetCommand; state: boolean | null }) => string
448
+ /**
449
+ * UI chrome language ("en" | "ja"; default "en"): selects the label catalog, the built-in
450
+ * data-validation messages, and is forwarded to the grid scrollbars (arrow labels,
451
+ * scroll-to-edge pills, "No items"). An unsupported value throws a RangeError during render —
452
+ * there is no language negotiation: hosts map `navigator.language` themselves. Unrelated to
453
+ * `formats.locale`, which is the BCP-47 value-formatting locale ("ja-JP" is NOT a valid value
454
+ * here).
455
+ * UI クローム言語 ("en" | "ja"、既定 "en"): ラベルカタログと組み込みデータ検証メッセージを選び、
456
+ * グリッドのスクロールバー (矢印ラベル・端スクロールピル・"No items") へ素通し。非対応値は render 中
457
+ * RangeError — 言語ネゴシエーションなし (`navigator.language` の写像はホスト側)。値書式の BCP-47
458
+ * ロケールである `formats.locale` とは無関係 ("ja-JP" はここでは不正値)。
459
+ */
460
+ locale?: SpreadsheetLocale
388
461
  /**
389
462
  * UI label vocabulary (v0.8 §1.6) — folds the former `sinkAriaLabel` / `rootAriaLabel` props
390
- * (clean cut-over). An omitted or explicitly `undefined` key keeps the English default. The
391
- * resolved `sinkAriaLabel` MUST mention "Escape" (WCAG 2.1.2 escape advisement, §10-4) — a
392
- * RangeError is thrown during render otherwise.
463
+ * (clean cut-over). 28 keys = the 7 engine chrome keys (scrollUp / scrollDown / scrollLeft /
464
+ * scrollRight / scrollToTop / scrollToBottom / noItems) + 21 own keys. An omitted prop or an
465
+ * explicitly `undefined` key keeps the value of the `locale` catalog. An unknown key or an
466
+ * empty / blank value throws a RangeError during render, and the resolved `sinkAriaLabel` MUST
467
+ * mention "Escape" (WCAG 2.1.2 escape advisement, §10-4).
393
468
  * UI ラベル語彙 (v0.8 §1.6) — 旧 sinkAriaLabel / rootAriaLabel prop を畳込み (クリーンカットオーバー)。
394
- * 省略 / 明示 undefined は英語既定。解決後の sinkAriaLabel は "Escape" を含むこと (違反は render 中
395
- * RangeError — §10-4)。
469
+ * 28 キー = エンジンクローム 7 キー (scrollUp / scrollDown / scrollLeft / scrollRight / scrollToTop /
470
+ * scrollToBottom / noItems) + 固有 21 キー。prop 省略 / 明示 undefined のキーは `locale` のカタログ値。
471
+ * 未知キー・空 / 空白値は render 中 RangeError、解決後の sinkAriaLabel は "Escape" を含むこと (§10-4)。
396
472
  */
397
473
  labels?: SpreadsheetLabelOverrides
398
474
  /**
@@ -453,7 +529,7 @@ export type SpreadsheetProps = {
453
529
  onClipChange?: (clip: ClipInfo | null) => void
454
530
  /** Hyperlink navigation seam (Alt+Enter, the link popover's Open button, and Ctrl / Meta+click on a link cell — spec 08 §11 D-53) — undeclared ⇒ no navigation, no Open button, no pointer gesture. / ハイパーリンク遷移シーム (Alt+Enter / Open ボタン / リンクセルへの Ctrl / Meta+クリック — 分冊 08 §11 D-53)。未宣言 = 遷移なし・Open ボタンなし・ポインタジェスチャなし。 */
455
531
  onHyperlinkOpen?: (url: string, cell: CellCoord) => void
456
- /** Strict validation rejection notice (v0.8 §1.9) — the commit never reaches the adapter; the message popover shows the rejection. / strict 検証拒否の通知 (commit はアダプタへ届かず、message ポップオーバーが拒否を示す)。 */
532
+ /** Strict validation rejection notice (v0.8 §1.9) — the commit never reaches the adapter; the message popover shows the rejection. `message` is `rule.errorMessage` when declared, otherwise the built-in message in the active `locale`. / strict 検証拒否の通知 (commit はアダプタへ届かず、message ポップオーバーが拒否を示す)。`message` は `rule.errorMessage` 宣言時はそれ、未宣言なら現在の `locale` の組み込みメッセージ。 */
457
533
  onValidationRejected?: (ctx: { cell: CellCoord; rule: DataValidationRule; text: string; message: string }) => void
458
534
  /** Ctrl+Alt+V request with the active cell's viewport rect (visual px, keyboard-origin shape of onCellContextMenu); undeclared ⇒ `paste({ mode: "formats" })`. / Ctrl+Alt+V の要求通知 (未宣言なら書式のみ貼り付け)。 */
459
535
  onPasteSpecialRequest?: (origin: { kind: "keyboard"; cellRect: { top: number; left: number; width: number; height: number } }) => void
@@ -510,21 +586,6 @@ const ZOOM_MAX = 4
510
586
  * fail-fast — Math.min/max は NaN を素通しし、全 ÷z 境界の汚染と同値バウンド無効化 (NaN 同士は
511
587
  * 不一致) による onZoomChange(NaN) 連射を招くため。
512
588
  */
513
- /**
514
- * Validates one overscan prop and returns it unchanged (fail-fast — the engine clamps invalid
515
- * overscan silently, so rejection is owned by this surface). Undefined is the declared absence
516
- * signal (engine default) and is exempt. Throws RangeError on a non-integer or negative value.
517
- * overscan prop 1 つの検証 (値はそのまま返す。fail-fast — エンジンは不正 overscan を無言
518
- * クランプするため拒否は本層所有)。undefined は宣言不在信号 (エンジン既定) で対象外。非整数・
519
- * 負値は RangeError。
520
- */
521
- const validateOverscan = (name: "overscanRows" | "overscanCols", value: number | undefined): number | undefined => {
522
- if (value !== undefined && !(Number.isInteger(value) && value >= 0)) {
523
- throw new RangeError(`[spreadsheet] ${name} must be a non-negative integer (got ${value})`)
524
- }
525
- return value
526
- }
527
-
528
589
  const clampZoom = (value: number): number => {
529
590
  if (!Number.isFinite(value)) {
530
591
  throw new RangeError(`[spreadsheet] zoom must be a finite number (got ${value})`)
@@ -542,6 +603,25 @@ const clampZoom = (value: number): number => {
542
603
  */
543
604
  const stepZoom = (value: number, direction: 1 | -1): number => (direction > 0 ? (Math.floor(value * 10 + 1e-6) + 1) / 10 : (Math.ceil(value * 10 - 1e-6) - 1) / 10)
544
605
 
606
+ /**
607
+ * Validates one overscan prop and returns it unchanged (fail-fast — the engine does NOT
608
+ * validate overscan: a negative value silently shrinks the rendering window past the visible
609
+ * band on the normal path and is clamped to 0 only on the huge-index path, so rejection is owned
610
+ * by this surface). Undefined is the declared absence signal (engine default) and is exempt.
611
+ * Throws RangeError on a non-integer or negative value (the message echoes the raw input —
612
+ * numbers verbatim incl. Infinity / NaN, strings quoted so a JS host passing "5" sees the quotes).
613
+ * overscan prop 1 つの検証 (値はそのまま返す。fail-fast — エンジンは overscan を検証しない: 負値は
614
+ * 通常経路で描画窓を可視帯の内側へ無言で縮め、巨大 index 経路でだけ 0 へクランプされるため、拒否は
615
+ * 本層所有)。undefined は宣言不在信号 (エンジン既定) で対象外。非整数・負値は RangeError (メッセージは
616
+ * 生の入力を映す — 数値は Infinity / NaN 込みでそのまま、文字列は引用符つき — JS ホストの "5" は引用符で映る)。
617
+ */
618
+ const validateOverscan = (name: "overscanRows" | "overscanCols", value: number | undefined): number | undefined => {
619
+ if (value !== undefined && !(Number.isInteger(value) && value >= 0)) {
620
+ throw new RangeError(`[spreadsheet] ${name} must be a non-negative integer (got ${typeof value === "string" ? JSON.stringify(value) : String(value)})`)
621
+ }
622
+ return value
623
+ }
624
+
545
625
  /**
546
626
  * Test-only seam props (NOT part of the public v0.1.0 contract — §8.2 counter injection).
547
627
  * テスト専用シーム props (公開 v0.1.0 契約外 — §8.2 カウンタ注入)。
@@ -560,6 +640,15 @@ type SpreadsheetTestProps = {
560
640
  * では渡さない (未指定 = 呼出しゼロ — `__onCellRender` と同じ品行)。
561
641
  */
562
642
  __onVirtualKeyboardRaise?: () => void
643
+ /**
644
+ * Test-only rendered-cell cap forwarded to the engine's `__maxRenderedCells` seam so the
645
+ * `onRenderTruncated` pass-through can be pinned without materializing 32,768 cells. Never
646
+ * passed in production (unset = the engine's MAX_RENDERED_CELLS — the `__onCellRender` conduct).
647
+ * テスト専用の描画セル上限 (エンジンの `__maxRenderedCells` シームへ素通し) — `onRenderTruncated`
648
+ * 素通しを 32,768 セルの実体化なしでピンするため。production では渡さない (未指定 = エンジンの
649
+ * MAX_RENDERED_CELLS — `__onCellRender` と同じ品行)。
650
+ */
651
+ __maxRenderedCells?: number
563
652
  }
564
653
 
565
654
  const DEFAULT_ROW_HEIGHT = 24
@@ -650,6 +739,28 @@ const requirePasteSheet = (sheet: PasteSheet | undefined): PasteSheet => {
650
739
  return sheet
651
740
  }
652
741
 
742
+ /**
743
+ * Keeps the previous engine label object while all 7 strings are value-equal, so the object handed
744
+ * to the memoized GridBody changes identity only when a string changes (a host passing an inline
745
+ * `labels={{…}}` must not re-render the grid body on every shell render).
746
+ * 7 文字列がすべて値等価の間は前回のエンジンラベル object を保持 — memo 化された GridBody へ渡す
747
+ * object の identity は文字列が変わった時だけ変わる (インライン `labels={{…}}` のホストでも shell
748
+ * 再レンダー毎に本体を再描画させない)。
749
+ *
750
+ * @param next - The engine labels resolved in this render / 今回の render で解決したエンジンラベル
751
+ * @returns `next`, or the previous object when every key is value-equal / `next`、全キー値等価なら前回の object
752
+ */
753
+ const useValueStableScrollLabels = (next: VirtualScrollLabels): VirtualScrollLabels => {
754
+ const stableRef = useRef<VirtualScrollLabels | null>(null)
755
+ const previous = stableRef.current
756
+ if (previous !== null && VIRTUAL_SCROLL_LABEL_KEYS.every((key) => previous[key] === next[key])) {
757
+ return previous
758
+ }
759
+ // レンダー時代入: 値等価比較の基準だけを保持し、副作用は持たない (破棄された render でも比較結果は同じ)
760
+ stableRef.current = next
761
+ return next
762
+ }
763
+
653
764
  /** Initial A1 selection. / 初期 A1 選択。 */
654
765
  const INITIAL_SELECTION: SelectionState = {
655
766
  ranges: [makeRange({ row: 0, col: 0 }, { row: 0, col: 0 })],
@@ -674,6 +785,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
674
785
  overscanRows,
675
786
  overscanCols,
676
787
  background,
788
+ onRenderTruncated,
677
789
  renderCell,
678
790
  onSelectionChange,
679
791
  onCellContextMenu,
@@ -691,6 +803,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
691
803
  formatCellAnnouncement,
692
804
  formatSelectionAnnouncement,
693
805
  formatCommandAnnouncement,
806
+ locale,
694
807
  labels,
695
808
  shortcuts,
696
809
  colors,
@@ -715,6 +828,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
715
828
  className,
716
829
  __onCellRender,
717
830
  __onVirtualKeyboardRaise,
831
+ __maxRenderedCells,
718
832
  }: SpreadsheetProps & SpreadsheetTestProps,
719
833
  ref,
720
834
  ): ReactNode {
@@ -730,8 +844,12 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
730
844
  const viewStore = viewStoreRef.current
731
845
 
732
846
  // ---- prop 時検証 (v0.8 §4.2.13 — clampZoom と同じく render 中に RangeError を投げる) ----
847
+ // 非対応ロケールはここで render 中 RangeError — 以降の検証メッセージ / GridBody は解決済み値だけを受ける
848
+ const uiLocale = resolveVirtualScrollLocale(locale)
733
849
  // ラベルの畳込み: resolveLabels が Escape 告知 (§10-4) を検証する (RC22)
734
- const resolvedLabels = useMemo(() => resolveLabels(labels), [labels])
850
+ const resolvedLabels = useMemo(() => resolveLabels(locale, labels), [locale, labels])
851
+ // GridBody は memo 境界 — インライン labels で毎回新 object になっても 7 文字列が同じなら identity を保つ
852
+ const scrollLabels = useValueStableScrollLabels(pickEngineLabels(resolvedLabels))
735
853
  // コマンド表: validateShortcuts の 5 条件 (+ 未知キー) は consumer の設定誤り — render 中に表面化
736
854
  const commandTable = useMemo(() => validateShortcuts(shortcuts), [shortcuts])
737
855
  // カスタム色スロット (各軸ちょうど 5 本の #rrggbb)
@@ -1830,10 +1948,12 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1830
1948
  * The validation gate (v0.8 §4.2.6 / 分冊 08 §4.2), shared by the editor's changed commit and
1831
1949
  * the editing-column `fillSelection` (which runs it per target cell): no rule / non-strict ⇒
1832
1950
  * ok (the cell keeps its invalid mark); only a strict rejection stops the commit and reports
1833
- * through `onValidationRejected` + the `message` popover (`rejected`).
1951
+ * through `onValidationRejected` + the `message` popover (`rejected`). The verdict message is
1952
+ * `rule.errorMessage` when declared, otherwise the built-in message in the resolved UI locale.
1834
1953
  * 検証ゲート (v0.8 §4.2.6 / 分冊 08 §4.2) — エディタの変更ある確定と編集列 fillSelection (対象セル毎)
1835
1954
  * が共有。規則なし / 非 strict は受理 (セルは不正表示のまま)、strict の拒否だけが commit を止めて
1836
- * onValidationRejected + message ポップオーバー (rejected) を出す。
1955
+ * onValidationRejected + message ポップオーバー (rejected) を出す。判定メッセージは `rule.errorMessage`
1956
+ * 宣言時はそれ、未宣言なら解決済み UI ロケールの組み込みメッセージ。
1837
1957
  */
1838
1958
  const validateCommit = useCallback(
1839
1959
  (cell: CellCoord, text: string): ValidationVerdict => {
@@ -1842,7 +1962,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1842
1962
  return OK_VERDICT
1843
1963
  }
1844
1964
  const evaluateFormula = adapter.evaluateFormula
1845
- const verdict = validateCellText(rule, text, evaluateFormula === undefined ? undefined : (formula) => evaluateFormula(formula, cell))
1965
+ const verdict = validateCellText(rule, text, uiLocale, evaluateFormula === undefined ? undefined : (formula) => evaluateFormula(formula, cell))
1846
1966
  if (verdict.ok || rule.strict !== true) {
1847
1967
  return OK_VERDICT
1848
1968
  }
@@ -1853,7 +1973,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1853
1973
  }
1854
1974
  return verdict
1855
1975
  },
1856
- [adapter, onValidationRejected],
1976
+ [adapter, onValidationRejected, uiLocale],
1857
1977
  )
1858
1978
  const editor = useCellEditor({
1859
1979
  enabled: !isEmpty,
@@ -2402,6 +2522,68 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2402
2522
  }
2403
2523
  }, [adapter, isEmpty, setSelection, engine, colHeaderMachine, rowHeaderMachine, headerResizeMachine, fillMachine, pointDrag, viewStore, editor.cancelEdit])
2404
2524
 
2525
+ // ---- 行数 / 列数の縮小 (同一 adapter — 絞り込み / カテゴリ / 期間の変更で明細が減る) は保持中の
2526
+ // 座標を新グリッドへ畳む (ADR-33) ----
2527
+ // 旧座標のレンジ / セッション / アンカー / クリップは新軸で範囲外になり得て、repaintOverlay の
2528
+ // sizeOf が例外を投げる (Fenwick は範囲外 index で fail-fast — 測る側は緩めない)。シート切替と違い
2529
+ // 座標系は同一グリッドなので全リセットではなく**クランプ**: 範囲内に残る選択 / セッション /
2530
+ // ポップオーバーは保持し、範囲外に出たものだけ端へ畳む (選択) か取り消す (セッション / ポップオーバー /
2531
+ // 自シートのクリップ)。成長 (行が増える) は座標を無効化しないので何もしない。同一性切替の effect より
2532
+ // 後に置き、エッジ ref に adapter を同乗させて「同一 adapter の縮小」だけを見る (切替は前段が A1 へ
2533
+ // 全リセット済み)。順序: **選択の畳込みを最初に**置く — 畳んだ選択は旧軸でも新軸でも範囲内なので、
2534
+ // 以降の取消 / 閉鎖 / 破棄が同期 repaint を呼んでも (現配線では repaintOverlay は passive effect 側の
2535
+ // 旧クロージャ = 旧軸、将来 layout effect 化しても新軸) どちらのクロージャでも安全 — 切替 effect の
2536
+ // 「防御的順序」を一段強めた形
2537
+ const prevCountsRef = useRef({ adapter, rows: rowAxis.count, cols: colAxis.count })
2538
+ const { getSessionCell, cancelEdit: cancelEditSession } = editor
2539
+ useLayoutEffect(() => {
2540
+ const prev = prevCountsRef.current
2541
+ const rows = rowAxis.count
2542
+ const cols = colAxis.count
2543
+ prevCountsRef.current = { adapter, rows, cols }
2544
+ if (prev.adapter !== adapter || (rows >= prev.rows && cols >= prev.cols)) {
2545
+ return
2546
+ }
2547
+ if (isEmpty) {
2548
+ // 空グリッドへの縮小: 0 行に A1 は存在せず、通知も読み上げも rearm も流さない (切替と同文)
2549
+ selectionRef.current = INITIAL_SELECTION
2550
+ setSelectionState(INITIAL_SELECTION)
2551
+ } else {
2552
+ // 選択は端へ畳む — 範囲外が無ければ同一 object が返り、ラッパーの Object.is bail で通知なし
2553
+ setSelection(clampSelectionToGrid(selectionRef.current, rows, cols))
2554
+ }
2555
+ // 飛行中ドラッグは復元なしで取り消す (各機械の dragBeforeRef は旧グリッド座標 — 切替シームと同文)
2556
+ engine.cancelDrag()
2557
+ colHeaderMachine.cancelDrag()
2558
+ rowHeaderMachine.cancelDrag()
2559
+ headerResizeMachine.cancelDrag()
2560
+ fillMachine.cancelDrag()
2561
+ pointDrag.cancelDrag()
2562
+ const outside = (cell: CellCoord): boolean => isEmpty || cell.row >= rows || cell.col >= cols
2563
+ // 範囲外セルの編集セッションは値破棄で終了 (commit すると消えた行へ誤書込) — 範囲内は保持
2564
+ const sessionCell = getSessionCell()
2565
+ if (sessionCell !== null && outside(sessionCell)) {
2566
+ cancelEditSession()
2567
+ }
2568
+ // ポップオーバーはアンカーが範囲外のものだけ畳む (repaintOverlay がアンカーを測る)
2569
+ const anchors = popoverAnchorsRef.current
2570
+ if (anchors.tip !== null && outside(anchors.tip)) {
2571
+ popoversRef.current?.hideTip()
2572
+ }
2573
+ if (anchors.interactive !== null && outside(anchors.interactive)) {
2574
+ // ホスト起点の閉鎖: フォーカスがグリッド外 (絞り込みの入力欄など) にある間は奪わない — 通常の
2575
+ // 閉鎖 (Escape / ボタン) と違い、ユーザーの打鍵先がここで変わってはならない
2576
+ const focusInsideGrid = rootRef.current?.contains(document.activeElement) === true
2577
+ popoversRef.current?.close({ restoreFocus: focusInsideGrid })
2578
+ }
2579
+ // 自シートのクリップは範囲外なら破棄 (元セルが消えた cut / copy — マーキーも範囲外を描かない)。
2580
+ // foreign (別アダプタのレンジ) は別座標系なので触れない
2581
+ const clip = getClip()
2582
+ if (clip !== null && !clip.foreign && outside({ row: clip.range.endRow, col: clip.range.endCol })) {
2583
+ discardClip()
2584
+ }
2585
+ }, [adapter, rowAxis.count, colAxis.count, isEmpty, setSelection, engine, colHeaderMachine, rowHeaderMachine, headerResizeMachine, fillMachine, pointDrag, getSessionCell, cancelEditSession, getClip, discardClip])
2586
+
2405
2587
  // ---- 凍結列数の変化 (v0.5 ライブ prop) は飛行中ジェスチャを復元なしで取り消す ----
2406
2588
  // 押下時の grab オフセット / アンカーは旧パーティション座標系で確立済み — 変更後の鮮度
2407
2589
  // ヒットテスト (新座標系) と混ざると、ユーザーが狙っていないセルへ fill が確定し得る
@@ -2635,6 +2817,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2635
2817
  overscanRows={validateOverscan("overscanRows", overscanRows)}
2636
2818
  overscanCols={validateOverscan("overscanCols", overscanCols)}
2637
2819
  background={background}
2820
+ onRenderTruncated={onRenderTruncated}
2638
2821
  renderCell={renderCell}
2639
2822
  onScroll={onScroll}
2640
2823
  onRangeChange={onRangeChange}
@@ -2643,9 +2826,12 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2643
2826
  requestCommit={requestCommit}
2644
2827
  testId={testId ? `${testId}-vs` : undefined}
2645
2828
  __onCellRender={__onCellRender}
2829
+ __maxRenderedCells={__maxRenderedCells}
2646
2830
  cfIndexRef={cfIndexRef}
2647
2831
  cfMemoRef={cfMemoRef}
2648
2832
  isPointing={isPointing}
2833
+ locale={uiLocale}
2834
+ scrollLabels={scrollLabels}
2649
2835
  />
2650
2836
  <SelectionOverlay
2651
2837
  rowAxis={rowAxis}
@@ -180,6 +180,8 @@ export type CellEditor = PointingSeams & {
180
180
  insertAtCaret: (text: string) => void
181
181
  /** The session's live text, or null when idle (editing-column fillSelection). / セッションの現在文字列 (idle は null — 編集列の fillSelection)。 */
182
182
  getSessionText: () => string | null
183
+ /** The session's cell, or null when idle — the count-shrink clamp cancels a session whose cell left the grid. / セッションのセル (idle は null) — 行数 / 列数縮小の畳込みが、グリッドから消えたセルのセッションを取り消す判定に使う。 */
184
+ getSessionCell: () => CellCoord | null
183
185
  /** Recomputes and re-applies the edit paint after an editing-column format toggle, then repaints the overlay. / 編集列の書式トグル後に塗りを再計算・再適用し overlay を再描画。 */
184
186
  refreshPaint: () => void
185
187
  /** React state mirror for the overlay's editing prop (§6.7.2-4). / overlay の editing prop 用ミラー。 */
@@ -655,6 +657,8 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
655
657
  return sessionRef.current === null || sink === null ? null : sink.value
656
658
  }, [])
657
659
 
660
+ const getSessionCell = useCallback((): CellCoord | null => (sessionRef.current === null ? null : sessionRef.current.cell), [])
661
+
658
662
  const refreshPaint = useCallback((): void => {
659
663
  const p = paramsRef.current
660
664
  const session = sessionRef.current
@@ -749,9 +753,10 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
749
753
  cycleAnchorAtCaret,
750
754
  insertAtCaret,
751
755
  getSessionText,
756
+ getSessionCell,
752
757
  refreshPaint,
753
758
  editing,
754
759
  }),
755
- [requestCommit, cancelEdit, startEdit, getPointing, togglePointingArm, getReferenceCursor, insertReference, captureSink, restoreSink, cycleAnchorAtCaret, insertAtCaret, getSessionText, refreshPaint, editing],
760
+ [requestCommit, cancelEdit, startEdit, getPointing, togglePointingArm, getReferenceCursor, insertReference, captureSink, restoreSink, cycleAnchorAtCaret, insertAtCaret, getSessionText, getSessionCell, refreshPaint, editing],
756
761
  )
757
762
  }
@@ -13,15 +13,27 @@
13
13
 
14
14
  import { memo, type ProfilerOnRenderCallback, type ReactNode, useEffect, useRef } from "react"
15
15
 
16
- /** Public custom cell renderer context (ADR-8). / 公開セルレンダラーの ctx (ADR-8)。 */
17
- export type RenderCellContext = { row: number; col: number; text: string }
16
+ /**
17
+ * Public custom cell renderer context (ADR-8). `text` is the RAW canonical `getCellText`
18
+ * string; `displayText` (v0.12) is the value-format display string — the exact string the
19
+ * default rendering, the AT announcement, the HTML clipboard and autofit all share (equals
20
+ * `text` when the format capability is absent). A custom cell that should look like the
21
+ * default renders `displayText`.
22
+ * 公開セルレンダラーの ctx (ADR-8)。`text` は素の正準 `getCellText` 文字列。`displayText`
23
+ * (v0.12) は値書式の表示文字列 — 既定描画・AT 読み上げ・HTML クリップボード・autofit が共有
24
+ * する同一文字列 (書式能力なしは `text` と同値)。既定と同じ見た目にしたいカスタムセルは
25
+ * `displayText` を描く。
26
+ */
27
+ export type RenderCellContext = { row: number; col: number; text: string; displayText: string }
18
28
 
19
29
  type CellViewProps = {
20
30
  row: number
21
31
  col: number
22
32
  /** Canonical text the grid's getCell reads from adapter.getCellText (updated by re-slice). / グリッドの getCell が adapter.getCellText から読む正準文字列。 */
23
33
  text: string
24
- /** The public cell renderer (ADR-8); this is its execution point. / 公開レンダラーの実行点。 */
34
+ /** Value-format display string (equals `text` without the format capability) — the default rendering and the opt-out landing. / 値書式の表示文字列 (書式能力なしは `text` と同値) — 既定描画とオプトアウトの着地。 */
35
+ display: string
36
+ /** The public cell renderer (ADR-8); this is its execution point. ONLY an `undefined` return is the per-cell opt-out (v0.12) — `null` / `false` / any other ReactNode is custom output (an empty custom cell). / 公開レンダラーの実行点。セル単位オプトアウト (v0.12) は `undefined` 返し**だけ** — `null` / `false` / 他の ReactNode はカスタム出力 (空のカスタムセル)。 */
25
37
  renderCell?: (ctx: RenderCellContext) => ReactNode
26
38
  /** Test-only re-render counter injection point (§8.2); undefined in production. / テスト専用注入点。 */
27
39
  __onCellRender?: ProfilerOnRenderCallback
@@ -31,7 +43,7 @@ type CellViewProps = {
31
43
  * Renders one cell's content (memo target — no own positioned element since v0.4).
32
44
  * セル 1 枚の中身を描画 (memo 対象 — v0.4 から自前の配置要素は持たない)。
33
45
  */
34
- const CellViewInner = ({ row, col, text, renderCell, __onCellRender }: CellViewProps): ReactNode => {
46
+ const CellViewInner = ({ row, col, text, display, renderCell, __onCellRender }: CellViewProps): ReactNode => {
35
47
  // テスト専用カウンタ: Profiler ノードの常設を避けるコスト取引 — 代わりに常設の passive
36
48
  // effect 1 本が毎 commit 走る (注入なしなら本体は即 no-op)。フックは条件分岐できないため
37
49
  // この登録コスト自体は受容する (初回 = mount、以後 = update)
@@ -41,7 +53,12 @@ const CellViewInner = ({ row, col, text, renderCell, __onCellRender }: CellViewP
41
53
  mountedRef.current = true
42
54
  __onCellRender?.(`cell-${row}-${col}`, phase, 0, 0, 0, 0)
43
55
  })
44
- return renderCell ? renderCell({ row, col, text }) : text
56
+ if (renderCell !== undefined) {
57
+ const custom = renderCell({ row, col, text, displayText: display })
58
+ // undefined はセル単位オプトアウト (v0.12) — 既定描画 (値書式込み) へ降りる。「空を描く」の明示は null
59
+ return custom === undefined ? display : custom
60
+ }
61
+ return display
45
62
  }
46
63
 
47
64
  /**