@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/CHANGELOG.md CHANGED
@@ -2,6 +2,114 @@
2
2
 
3
3
  All notable changes to `@aiquants/spreadsheet` are documented here.
4
4
 
5
+ ## 0.13.0 (2026-09-24)
6
+
7
+ v0.13 は UI クロームの多言語化の列車 — 英語 (`"en"`) を既定、日本語 (`"ja"`) を第 2 言語とし、ラベル・組込み
8
+ 検証メッセージ・グリッドのスクロールバーを 1 本の `locale` プロップで切り替える。ピア `@aiquants/virtualscroll`
9
+ 3.7.0 のラベルカタログ (`VIRTUAL_SCROLL_LABEL_CATALOGS` / `resolveVirtualScrollLabels`) を単一真実源として再利用し、
10
+ エンジン由来の文字列は再記述しない。
11
+
12
+ - **新機能 — `locale` プロップ (UI クローム言語)**: `SpreadsheetProps.locale?: SpreadsheetLocale` (`"en"` | `"ja"`、
13
+ 既定 `"en"` — 型 `SpreadsheetLocale` はエンジンの `VirtualScrollLocale` そのもの)。ラベルカタログ、組込み検証
14
+ メッセージ、グリッドの縦 / 横スクロールバー (矢印名とスクロール行 0 の空状態「No items」/「項目がありません」) を
15
+ 選ぶ。非対応値 (`"fr"` / `"ja-JP"` / `"EN"` / `""` / `null`) は render 中 `RangeError` (0 行グリッドでも) —
16
+ 言語ネゴシエーションはしない (`navigator.language` の写像はホスト側、対応一覧はピアの `VIRTUAL_SCROLL_LOCALES`)。
17
+ 値書式の `formats.locale` (BCP-47) とは別軸。`lang` 属性は付けない。`locale` 省略と明示 `"en"` の DOM は
18
+ byte 同一 (v0.12 と同じ英語)。ライブ切替はグリッドを再マウントせず、選択・編集セッション・スクロール位置を
19
+ 保ったまま文言だけ差し替える — 既知の制約: 開いたままの message ポップオーバーは提示時の言語のまま
20
+ (再提示で追随)。
21
+ - **新機能 — ロケール別ラベルカタログ**: `SPREADSHEET_LABEL_CATALOGS` (`en` / `ja`、外側と各カタログを凍結) と
22
+ `resolveLabels(locale, overrides)` を公開。`SpreadsheetLabels` は 28 キー = エンジン 7 キー (`scrollUp` /
23
+ `scrollDown` / `scrollLeft` / `scrollRight` / `scrollToTop` / `scrollToBottom` / `noItems` — 値は
24
+ `VIRTUAL_SCROLL_LABEL_CATALOGS` を再利用) + 自前 21 キー。`labels` で渡したエンジンキーもグリッドの
25
+ スクロールバーへ届く。GridBody へ渡すエンジン部分集合は 7 文字列の値で安定化し、インラインの
26
+ `labels={{…}}` でも本体の memo 境界を貫通しない。`en` は v0.12 までの英語既定と byte 同一。
27
+ - **新機能 — 組込み検証メッセージのロケール化**: `errorMessage` の無い拒否文は型 × op ごとの全文テンプレート
28
+ (日本語は境界が先に来る語順のため断片翻訳でなく文ごと) を `locale` から選ぶ — 例 `"Value must be a number
29
+ greater than 5."` / `"5 より大きい数値を入力してください。"`。`onValidationRejected` の `message` と message
30
+ ポップオーバー (題「Data validation」/「データの入力規則」) に効く。`errorMessage` (空文字含む) は従来どおり
31
+ 言語に依らず優先。カタログは内部 (barrel 非公開)。
32
+ - **破壊的変更 (0.x 枠 — クリーンカットオーバー、互換別名なし)**:
33
+ - `DEFAULT_LABELS` を削除。移行: `DEFAULT_LABELS` → `SPREADSHEET_LABEL_CATALOGS.en`。
34
+ - 読む箇所の無かった 4 キー `validationRejected` / `indicatorNote` / `indicatorComment` / `indicatorInvalid`
35
+ を削除 (セル指標は属性だけの視覚フックでアクセシブルテキストを持たない)。移行: `labels` から当該キーを外す。
36
+ - `labels` の未知キー (上の退役キーを含む) と空白 / 非文字列の値は render 中 `RangeError` (従来は未知キーを
37
+ 黙って無視、空文字を受理)。
38
+ - `validateCellText(rule, text, evaluate?)` → `validateCellText(rule, text, locale, evaluate?)` — 第 3 引数
39
+ `locale` は必須で判定より先に検証 (`undefined` / 非対応値は `RangeError` — 純関数に既定値を隠さない)。
40
+ 移行: `validateCellText(rule, text, evaluate)` → `validateCellText(rule, text, "en", evaluate)`。
41
+ - ピア `@aiquants/virtualscroll` は ^3.7.0 が必要 (ラベルカタログ・解決器と `locale` / `labels` プロップ)。
42
+ - **デモの多言語化**: `?lang=en|ja` が言語の単一真実源 (既定 en — 不正値は `RangeError` で空白ページ)。ナビ末尾の
43
+ 切替 `[data-demo-locale-select]` は再読込なしで URL・`<html lang>`・タブ表題・全ルートのクロームを差し替え、
44
+ ナビのリンクは `?lang` を運ぶ。全 `<Spreadsheet>` へ `locale` を渡す。セルのサンプルデータ (シード /
45
+ フィクスチャ / 読み取り専用ストア) は言語不変で日本語のまま (E2E の検査対象そのもの)。ページ別カタログは
46
+ 型付き・凍結で、カタログ census (`catalogIssues` — キー過不足 / 空白 / ja = en / 和文スペース規則) を持つ。
47
+ - **E2E**: 既定経路は英語クローム — 既存 14 spec のクローム文言 assertion を en カタログ値へ置換 (件数不変)。
48
+ 新設 `tests/e2e/i18n.spec.ts` 8 が `?lang=ja` 経路 (ポップオーバー / 読み上げ / 拒否トースト / スクロールバー
49
+ 矢印) とライブ切替 (再読込・再マウントなしの保持) と不正 `?lang` の fail-fast、ja-JP ブラウザでも既定は英語
50
+ (ブラウザ言語からの言語ネゴシエーションなし) を実ブラウザで固定 (24 ファイル 271 テスト)。
51
+ - **検証 — 走行台帳 (census の錨は本未リリース節)**: 単体 / 統合 **92 ファイル 4,083 テスト** 全緑 —
52
+ 0.12.0 出荷時の 82 ファイル 3,851 に v0.13 進行分 +232 (新規 `validationMessages` 11 + `localePassthrough` 9 +
53
+ デモ側 8 ファイル 153 (`locale` 18 / `common` 21 / `corePages` 24 / `gridPages` 28 / `toolPages` 15 /
54
+ `stylesPage` 17 / `stylesPanels` 24 / `readOnlyRejection` 6)、既存増分 `labels` +27 / `Spreadsheet` +8 /
55
+ `dataValidation` +7 / `GridBody` +3 / `popovers` +2 / popover 部品 +9 / `editRejection` +2、0.12.0 節の
56
+ 出荷時凍結ピンで `hxCensus` +1)。公開面は `index.ts` の値 40 + 型 98 = 138 export (`DEFAULT_LABELS` 退役、
57
+ `SPREADSHEET_LABEL_CATALOGS` / `resolveLabels` / 型 `SpreadsheetLocale` 追加)。
58
+
59
+ ## 0.12.0 (2026-09-22)
60
+
61
+ v0.12 はセルの見た目をホストへ渡す 2 つの継ぎ目 — `getCellAttributes` (ホスト `data-*` 属性の素通し) と
62
+ `renderCell` のセル単位オプトアウト — の列車 (ADR-32)。消費側がセル内へ器 (ラッパー div) を敷いて地色・
63
+ ホバー・テーマトークンを描いていた DOM 税と、「1 列でも自前描画なら全列自前」の全か無かを解消する。
64
+
65
+ - **新機能 — `SpreadsheetDataAdapter.getCellAttributes` (12 番目の optional・非 aspect 能力)**: セル毎の
66
+ ホスト所有 `data-*` 属性をパッケージ所有のセル要素 (`[data-aqsp-cell]`) へそのまま載せる継ぎ目。値の
67
+ 解釈はパッケージが持たない — 行状態の塗り分け・`:hover`・テーマトークン (`var(--…)`) は全てホスト
68
+ CSS 側で書ける (`[data-aqsp-cell][data-row-state="dirty"] { background: var(--…) }`)。render 時
69
+ fail-fast 検証 (`resolveCellAttributes` — 戻りはプレーン record か null、キーは小文字 `data-[a-z0-9-]+` のみ、
70
+ パッケージ / エンジン放出の `data-aqsp*` / `data-aqvs*` / `data-row` / `data-col` は予約、値は string のみ —
71
+ 違反は名指しの RangeError)。描画セル毎・スライス毎 1 読み (getCellText と
72
+ 同格予算)、変更は `dataVersion` で公開、セル毎 `null` = 追加属性なし。未宣言 = v0.11 のセル DOM と
73
+ バイト同一。
74
+ - **新機能 — `renderCell` セル単位オプトアウト + `RenderCellContext.displayText`**: `undefined` を返した
75
+ セルはパッケージ既定描画 (値書式エンジン込み) へ降りる — 「要る列だけカスタム、残りは既定」が器なしで
76
+ 構成できる (v0.7 の「renderCell は値書式を全滅させる」グリッド単位ゲートは退役。オプトアウトは
77
+ `undefined` だけ — `null` / `false` / 他の ReactNode はカスタム出力 = 空のカスタムセル)。ctx は素の `text` (v0.7 恒等) に加えて `displayText` (値書式の表示文字列 — 既定描画・
78
+ AT 読み上げ・HTML クリップボード・autofit 計測と同一) を運び、「画面には効かないのに読み上げとコピー
79
+ には効く」無言のずれを構造的に断つ。autofit は `renderCell` 指定中はグリッド全体 no-op のまま
80
+ (カスタム DOM の幅は測れない)。
81
+ - **修正 — 行数 / 列数が縮むと保持中の選択で `Index out of bounds` (ADR-33)**: 同一 adapter のまま `rowCount` / `colCount`
82
+ が選択・編集セッション・ポップオーバー・クリップの位置より小さくなる (絞り込み / カテゴリ切替 / 期間変更) と、
83
+ オーバーレイ幾何が消えた行を Fenwick 木へ測りに行って送出し React のエラー境界へ落ちていた (0.10.0 / 0.11.0 で
84
+ 再現 — 消費側 issue)。パッケージが座標を新グリッドへ畳む: 選択は `clampSelectionToGrid` (各レンジの隅・anchor・
85
+ active を末尾行 / 列へ、丸ごと外のレンジは端へ潰す。範囲外なしは identity = 通知なし、畳んだら `onSelectionChange`
86
+ 1 回)、範囲外セルの編集セッションは値破棄 (`getSessionCell` 新設)、アンカーが範囲外のポップオーバー / tip は閉鎖、
87
+ 自シートの範囲外クリップは破棄、飛行中ドラッグは取消、空グリッドへの縮小は通知なしの A1 直接リセット。範囲内に
88
+ 残るものは保持し (絞り込みで入力中のセルを失わせない)、成長は何もしない。適用点はシート切替 effect 直後の
89
+ `useLayoutEffect` (畳込みを取消 / 閉鎖 / 破棄より先に置く — 畳んだ選択は新旧どちらの軸でも範囲内) — passive effect
90
+ では Fenwick の範囲外 throw が再現する (変異 CS-4 実測)。測る側の fail-fast は緩めない (黙って 0 を返すと選択枠が
91
+ 誤位置に描かれる)。特性: 畳込みは `onSelectionChange` を layout effect から 1 回発火 (ホスト起点)、ポップオーバー
92
+ 閉鎖はフォーカスがグリッド外 (絞り込み入力欄) にある間は打鍵先を奪わない (`close({ restoreFocus: false })`)、保持
93
+ された選択 / セッションは index 意味論、生存セッションの数式トークンが消えた行を指してもキーボード参照
94
+ ポインティングは消費点で `clampCoord` に畳んでから歩く (`useGridKeyboard`)。新規 [countShrink.spec.tsx](<./src/countShrink.spec.tsx>) 13
95
+ (issue の再現手順そのまま / レンジ切詰め / 通知 1 回・非影響 0 回 / 列軸 / 空グリッド往復 (通知ゼロ) / 編集セッションの
96
+ 取消と保持 / クリップ破棄 (dataVersion 据え置き経路) と範囲内保持と foreign 不触 / ポップオーバー閉鎖と範囲内保持と
97
+ フォーカス中立 / 数式ポインティングの消費点 clamp) + `selectionState` +3。変異 CS-1 〜 CS-5 (分冊 07
98
+ §5 台帳) red 実測 — クリップ破棄は dataVersion 増分経路では既存契約が先に破棄するため、ピンを据え置き経路へ是正。
99
+ - **修正 (v0.11 の検証所見)**: `validateOverscan` の挿入で切り離されていた `clampZoom` の JSDoc を復旧。overscan の
100
+ 説明を是正 — エンジンは overscan を検証しない (負値は描画窓を無言で縮める)、overscan は行数で数え非表示行も枠を
101
+ 消費する、予算超過はエンジンが有界化でなく**打切り** (先頭側を残す — 行予算超過は無言、セル上限超過は列脱落) で
102
+ 応じる — を明記し、Infinity も拒否 (メッセージは生入力を映す — 数値はそのまま、文字列は引用符つき)。`background` の identity 安定契約 (インライン要素は
103
+ GridBody memo を貫通) と凍結列域まで広がる範囲を明記。**新設 `onRenderTruncated`** — VirtualGrid の打切り通知の
104
+ 素通し (列脱落を可観測に — ピンはテストシーム `__maxRenderedCells` で 3 万セルを実体化せずに固定)。background 省略
105
+ テストを空砲から直下要素数の対照ピンへ置換。
106
+ - **検証 — 走行台帳 (census の錨は本未リリース節)**: 単体 / 統合 **82 ファイル 3,851 テスト** 全緑 —
107
+ 0.11.0 出荷時の 79 ファイル 3,815 に v0.12 進行分 +36 (新規 `renderCellOptOut` 5 + `cellAttributes` 5 +
108
+ `countShrink` 13、既存増分 `dataAdapter` +7 / `CellView` +1 / `selectionState` +3 / `overscanPassthrough` +1、
109
+ 0.11.0 節の出荷時凍結ピンで `hxCensus` +1)。変異 4 本 red
110
+ 実測 (キー文法ガード除去 / GridBody のホスト属性 merge 脱落 / CellView オプトアウト着地除去 / 値書式の
111
+ renderCell 再ゲート) — バイト復元済み。
112
+
5
113
  ## 0.11.0 (2026-09-22)
6
114
 
7
115
  v0.11 はエンジン素通し 3 プロップ (`overscanRows` / `overscanCols` / `background`) — 高速スクロールで
package/README.md CHANGED
@@ -13,11 +13,11 @@ deep-reach proof route.
13
13
  - 📋 **Real clipboard, Excel-compatible TSV**: only cells containing tabs/newlines are quoted (a literal `"hi"` survives untouched), dual `text/plain` + `text/html` payloads (`mso-data-placement:same-cell`), tiling paste onto integer-multiple ranges, cut-move — and **both copy and cut arm an animated marching-ants marquee** (Excel-style; a copy's ants survive paste for repeated pasting, Escape or editing dismisses them; static under `prefers-reduced-motion`)
14
14
  - ✏️ **In-place editing**: F2 / double-click (caret lands on the clicked character in real browsers) / type-to-replace, Alt+Enter multiline, full IME composition support (Safari and Chrome event orders both handled)
15
15
  - 🧮 **Consumer-owned data**: a 4-member adapter (`rowCount` / `colCount` / `getCellText` / `applyEdits`) is the whole data contract — bring your own store, undo stack, and validation; an optional `findDataEdge` capability unlocks Excel Ctrl+Arrow data-edge jumps, and an optional `getCellFormat` capability (v0.7) adds built-in, totally fail-soft display formats —
16
- value-aware auto-alignment (values that actually parse go right, labels stay left) plus number/currency/percent/scientific/date value formats over the display string (exact-decimal Intl formatting, interned formatters, timezone-proof pure-calendar dates), editing and copy still carrying the raw value — without a `renderCell`
16
+ value-aware auto-alignment (values that actually parse go right, labels stay left) plus number/currency/percent/scientific/date value formats over the display string (exact-decimal Intl formatting, interned formatters, timezone-proof pure-calendar dates), editing and copy still carrying the raw value — for every cell that is not custom-rendered (since v0.12 a `renderCell` hands individual cells back to this default by returning `undefined`)
17
17
 
18
18
  - 🕳️ **Hidden tracks are first-class**: zero-size rows/columns render nothing, arrows and Shift-arrows jump a hidden run in one keystroke, Home/End land on visible tracks, and rendering stays fast across 100,000-column hidden runs
19
19
  - 🛡️ **Never silently freezes**: every bulk operation (copy / cut / delete / paste / fill / resize fan-out / autofit) is size-estimated **before** any expansion and rejected past its `maxOperationCells`-derived ceiling with a typed callback
20
- - ♿ **Screen-reader announcements (opt-in)**: provide the formatter props and cell/selection changes are announced via a debounced live region — the package ships no built-in strings
20
+ - ♿ **Screen-reader announcements (opt-in)**: provide the formatter props and cell/selection changes are announced via a debounced live region — the announcement wording is always yours (the built-in `locale` catalogs cover UI chrome only)
21
21
  - 🧊 **Frozen columns & rows** (v0.5 / v0.6): `frozenColumns` pins the first N columns as a static left band and `frozenRows` pins the first N rows as a static top band — two-band headers, a Cartesian-product 4-quadrant clipped selection overlay, partition-aware hit-testing and ensure-visible on both axes, all riding the engine's anchor-free frozen bands
22
22
  - 📌 **Trailing freeze & strong freeze dividers** (v0.9): `frozenTrailingColumns` / `frozenTrailingRows` pin the **last** N columns / rows (a fixed totals row is the canonical use) as static right / bottom bands over the **same data** (count-based, Glide-style — no extra-row API), with three-band headers,
23
23
  a {main, frozen, scroll, trailing}² clipped selection overlay, three-way partitioned hit-testing / ensure-visible, near-edge resize strips with pinned-right semantics, and band-side unhide markers; **every** freeze boundary (leading and trailing, both axes) draws a strong Excel / Sheets-style
@@ -26,6 +26,9 @@ deep-reach proof route.
26
26
  and every write whose target set touches a read-only cell (Delete / paste / cut both walls / fill with diagonal L-legs decomposed / format / self-calc / annotations) refuses the **whole operation** before the adapter is reached (Excel-style, no per-cell skip); painted cells carry
27
27
  `aria-readonly="true"` + `data-aqsp-readonly` (style it yourself — no package CSS), refusals speak through the single sealed `onReadOnlyRejected` (+ opt-in `formatReadOnlyAnnouncement`), and copying or viewing notes / comments / links stays legal (content-aware chords,
28
28
  viewer-mode popovers with the commit affordance disabled); undeclared ⇒ byte-identical v0.9 behavior
29
+ - 🎛️ **Host-owned cell appearance without wrappers** (v0.12 — ADR-32): one optional adapter read `getCellAttributes(row, col)` stamps host `data-*` attributes verbatim onto the package-owned cell element, so row-state tints, `:hover` and theme tokens (`var(--…)`) are plain host CSS
30
+ (`[data-aqsp-cell][data-row-state="dirty"] { background: var(--…) }`) — no per-cell wrapper divs; and `renderCell` gains a per-cell opt-out: return `undefined` and that one cell falls back to the package default rendering, value-format engine included (`ctx.displayText` carries the exact
31
+ display string the default rendering, announcements, the HTML clipboard and autofit share)
29
32
  - 🎨 **Rich cells as capability declarations** (v0.8 — ADR-13): eight optional adapter reads (`getCellFormat` / `getCellFormula` / `getCellNote` / `getCellComments` / `getCellHyperlink` / `getCellValidation` / `getConditionalFormats` / `getRangeExtrema` + `evaluateFormula`) unlock the full cell
30
33
  format model (value format, alignment, vertical alignment, wrap, background / font colour, bold / italic / underline / strikethrough, font family / size in pt, per-side borders with Excel-style ownership folding), note / comment / link indicators, paint-time validation marks and sheet-level
31
34
  conditional formats (cell value / text / empty / colour scale / custom formula, 2-D bucket index, first-match-per-property precedence) — every undeclared aspect keeps the v0.7 DOM byte-identical, and writes go through a patch-typed `applyEdits(edits, context)`
@@ -36,8 +39,9 @@ deep-reach proof route.
36
39
  - ⌨️ **Rebindable Sheets-style commands** (ADR-17): 70 `SpreadsheetCommand` literals (bold / italic / underline / strikethrough / alignment / number formats / borders / links / notes / comments / fill down / right / selection / date / time / hide / unhide / paste special, plus unbound extension
37
40
  commands for colours, self-calc and highlight toggles) driven by ONE built-in table (`BUILTIN_ROWS`), a canonical chord grammar (`Ctrl+Alt+Shift+KEY`, letters by the produced character or the `code` letter, everything else by `event.code`), prop-time validation with `RangeError`s, and an
38
41
  imperative `executeCommand` / `applyFormat` / `setBorders` / `setNote` / `addComment` / `setHyperlink` / `setValidation` handle group
39
- - 💬 **Popover layer and highlight bands** (ADR-15): note editor + hover note tip, comment thread, hyperlink editor, validation list picker and validation message on two resident anchors positioned by the overlay (zero DOM for a v0.7 adapter with default props, `renderPopover` to replace the defaults, a 24-key `labels` bag folding the former `sinkAriaLabel` / `rootAriaLabel` props), and Excel-style active row / column / intersection highlight bands (transform-only per scroll frame, `--aqsp-band-*` colours, controlled / uncontrolled `highlight` state)
42
+ - 💬 **Popover layer and highlight bands** (ADR-15): note editor + hover note tip, comment thread, hyperlink editor, validation list picker and validation message on two resident anchors positioned by the overlay (zero DOM for a v0.7 adapter with default props, `renderPopover` to replace the defaults, a `labels` bag folding the former `sinkAriaLabel` / `rootAriaLabel` props — see "Localization"), and Excel-style active row / column / intersection highlight bands (transform-only per scroll frame, `--aqsp-band-*` colours, controlled / uncontrolled `highlight` state)
40
43
  - 🧭 **Formula reference pointing**: with `getCellFormula` declared, typing `=SUM(` and clicking, dragging or arrowing over cells inserts / replaces the reference token at the caret (`A1`, `A1:B2`, `A:A`, `1:1`), F4 cycles `$` anchors, F2 toggles the arm — the editing session, selection and sink position stay put
44
+ - 🌐 **English / Japanese UI chrome** (v0.13): one `locale` prop (`"en"` default, `"ja"`) switches the accessible names, popover strings, built-in validation messages and the grid scrollbars' arrow names / empty state — live, without remounting the grid; unsupported values throw a `RangeError` (no language negotiation — see "Localization")
41
45
  - 🔧 **TypeScript**: complete type definitions, ESM + CJS artifacts
42
46
 
43
47
  ## Installation
@@ -46,7 +50,7 @@ deep-reach proof route.
46
50
  pnpm add @aiquants/spreadsheet @aiquants/virtualscroll
47
51
  ```
48
52
 
49
- `react` / `react-dom` (`^19.2`) and `@aiquants/virtualscroll` are peer dependencies.
53
+ `react` / `react-dom` (`^19.2`) and `@aiquants/virtualscroll` (`^3.7.0` — its label catalogs back the `locale` prop) are peer dependencies.
50
54
 
51
55
  > **Browser floor**: the built-in cell display formats (v0.7) require `Intl.NumberFormat` v3
52
56
  > (string mathematical-value input + `signDisplay: "negative"`) — **Chrome/Edge 106+, Firefox 116+,
@@ -106,7 +110,7 @@ The host element must have a resolvable height — the grid fills its parent.
106
110
  | `getCellText(row, col)` | Pure, synchronous read. Called for visible cells during render, for **every cell of a copied/cut range** (bounded by `maxOperationCells`), for the source cells of a fill and the measured cells of an autofit (both bounded — see "Operation limits"), and for the active cell when announcements are enabled — a viewport-only lazy store is not sufficient. |
107
111
  | `applyEdits(edits, context)` | One call = **one transaction** (a paste, a Delete, a fill, a cell commit, a format command, a self-calc, an annotation write each arrive as a single call). Each `CellEdit` is a **patch** (`{ row, col } & CellPatch` — `text?` / `format?` / `note?` / `comments?` / `hyperlink?` / `validation?`): an absent key leaves that aspect untouched, `null` clears it (`text` has no `null` — clear is `""`), `format` is always the WHOLE canonical `CellFormat` the package computed (store it verbatim and return it from `getCellFormat`), `comments` is the whole thread (entries with an `id` are kept verbatim, entries without one are new — YOU mint `id` / `author` / `createdAt`). Edits apply in array order with **per-key last-wins** duplicates (a later edit's keys override, its omitted keys keep the earlier value — cut-move relies on it). `context` is the discriminated `EditContext` (see "Editing, Undo, and rejection"): `{ transaction, source }` or, for a paste, `{ transaction, source: "paste", sameSheet, conditionalFormats }`. Throw to reject — the grid keeps or discards the in-flight state safely and fires `onEditRejected(error, rejection)`. |
108
112
  | `findDataEdge(from, direction)` | **Optional capability.** When present, Ctrl+Arrow / Ctrl+Shift+Arrow jump to the Excel data edge instead of the grid edge; absence keeps the grid-edge jump (a capability declaration, not a value fallback). You implement the Excel rule — non-empty cell with a non-empty neighbor: end of the contiguous run; otherwise: the next non-empty cell in `direction`; none: the grid edge — because only the store can jump in O(occupied cells). Returns are validated **fail-fast before dispatch**: non-integer, out-of-axis, or off-row/column coordinates throw a `RangeError` that propagates uncaught. Reference implementation: the `/edit` demo store. |
109
- | `getCellFormat(row, col)` | **Optional capability** (v0.7) — the cell's built-in display format: `{ align?, value? }`. `align` (`"left"`/`"center"`/`"right"`, undefined = **auto by the actual value**: text that parses under the cell's numeric/date format — or as a strict decimal with no format — goes right; labels stay left, so a `"Total"` in a currency column is never a right-aligned impostor) lands on the cell via inline `justify-content` + a `data-aqsp-align` attribute, and the in-place editor's textarea adopts the same alignment while editing (Excel parity); `value` (`number`/`percent`/`currency` (**`currency` code required** — no region default is assumed)/`scientific`/`date`/`text`) formats the **display** string. Absence keeps the v0.1 plain, left-aligned rendering. The engine is **exact**: the validated raw string is handed to `Intl.NumberFormat` **as a string** (NumberFormat v3 mathematical values — no binary-double round-trip, so percent `scale: false` boundaries and >2^53 integers stay exact), formatters are **interned** per option tuple (construction failures too — a broken column throws once, not per cell), negative zero is suppressed via `signDisplay: "negative"` (locale-proof), and dates are **pure calendar math** — no `Date` object, so host timezones, DST gaps, and two-digit-year remapping can never corrupt or reject a real date; with `decimals` omitted, `number` keeps every typed fraction digit (Excel General parity). Formatting is **totally fail-soft** — a non-numeric string (hex/binary like `"0x10"` stays raw), an invalid/rollover/mixed-separator date (`"2026-02-30"`, `"2026-09/02"`), or an out-of-range `Intl` option all render the raw text. It applies to **display only** — editing reads the raw `getCellText` (Excel parity), and copy/TSV carry the raw value too (a **deliberate deviation** from Excel — see the deviations table). Column autofit measures the **formatted** display string, and screen-reader announcements receive both the raw `text` and the formatted `displayText`. `renderCell` takes precedence over the built-in **value format** only; the resolved **alignment** is a cell property that still applies under `renderCell`. Return values are re-read only on a re-slice, so publish a live format change by bumping `dataVersion` (or swapping the adapter). Return a shared/interned `CellFormat` per column. The exported `formatCellValue(text, value)` is the same engine, reusable inside `renderCell`. Reference: the `/format` demo. **v0.8** widens `CellFormat` to the full rich model — `verticalAlign` (`"top"` / `"middle"` / `"bottom"`), `wrap`, `background`, `color`, `bold` / `italic` / `underline` / `strikethrough`, `fontFamily`, `fontSize` (pt), `borders` (`{ top?, right?, bottom?, left? }` of `{ style: "thin" \| "medium" \| "thick" \| "dashed" \| "dotted" \| "double" \| "hair"; color? }`, default colour `BORDER_DEFAULT_COLOR` = `#000000`) — painted as inline styles + `data-aqsp-valign` / `data-aqsp-wrap` state attributes; borders follow Excel ownership (a cell draws its own right / bottom, a neighbour's left / top folds onto them, own wins) and an unparseable colour or style skips that property alone. Formats are compared and interned by their **canonical form** (`canonicalizeFormat` — sorted keys, no `undefined` / `false`), and `mergeFormat(base, patch)` returns `base` by identity when nothing changes, so a command over 10,000 cells sharing one interned base writes ONE format object. This capability also gates every format command, `handle.applyFormat` / `setBorders`, the `formats` / `noBorders` paste modes and conditional formats. |
113
+ | `getCellFormat(row, col)` | **Optional capability** (v0.7) — the cell's built-in display format: `{ align?, value? }`. `align` (`"left"`/`"center"`/`"right"`, undefined = **auto by the actual value**: text that parses under the cell's numeric/date format — or as a strict decimal with no format — goes right; labels stay left, so a `"Total"` in a currency column is never a right-aligned impostor) lands on the cell via inline `justify-content` + a `data-aqsp-align` attribute, and the in-place editor's textarea adopts the same alignment while editing (Excel parity); `value` (`number`/`percent`/`currency` (**`currency` code required** — no region default is assumed)/`scientific`/`date`/`text`) formats the **display** string. Absence keeps the v0.1 plain, left-aligned rendering. The engine is **exact**: the validated raw string is handed to `Intl.NumberFormat` **as a string** (NumberFormat v3 mathematical values — no binary-double round-trip, so percent `scale: false` boundaries and >2^53 integers stay exact), formatters are **interned** per option tuple (construction failures too — a broken column throws once, not per cell), negative zero is suppressed via `signDisplay: "negative"` (locale-proof), and dates are **pure calendar math** — no `Date` object, so host timezones, DST gaps, and two-digit-year remapping can never corrupt or reject a real date; with `decimals` omitted, `number` keeps every typed fraction digit (Excel General parity). Formatting is **totally fail-soft** — a non-numeric string (hex/binary like `"0x10"` stays raw), an invalid/rollover/mixed-separator date (`"2026-02-30"`, `"2026-09/02"`), or an out-of-range `Intl` option all render the raw text. It applies to **display only** — editing reads the raw `getCellText` (Excel parity), and copy/TSV carry the raw value too (a **deliberate deviation** from Excel — see the deviations table). Column autofit measures the **formatted** display string, and screen-reader announcements receive both the raw `text` and the formatted `displayText`. A `renderCell` cell that returns custom output shows it instead of the built-in **value format** (both the raw `text` and the formatted `displayText` reach the renderer); a cell whose `renderCell` returns `undefined` falls back to the formatted default (v0.12 per-cell opt-out); the resolved **alignment** is a cell property that applies under `renderCell` regardless. Return values are re-read only on a re-slice, so publish a live format change by bumping `dataVersion` (or swapping the adapter). Return a shared/interned `CellFormat` per column. The exported `formatCellValue(text, value)` is the same engine, reusable inside `renderCell`. Reference: the `/format` demo. **v0.8** widens `CellFormat` to the full rich model — `verticalAlign` (`"top"` / `"middle"` / `"bottom"`), `wrap`, `background`, `color`, `bold` / `italic` / `underline` / `strikethrough`, `fontFamily`, `fontSize` (pt), `borders` (`{ top?, right?, bottom?, left? }` of `{ style: "thin" \| "medium" \| "thick" \| "dashed" \| "dotted" \| "double" \| "hair"; color? }`, default colour `BORDER_DEFAULT_COLOR` = `#000000`) — painted as inline styles + `data-aqsp-valign` / `data-aqsp-wrap` state attributes; borders follow Excel ownership (a cell draws its own right / bottom, a neighbour's left / top folds onto them, own wins) and an unparseable colour or style skips that property alone. Formats are compared and interned by their **canonical form** (`canonicalizeFormat` — sorted keys, no `undefined` / `false`), and `mergeFormat(base, patch)` returns `base` by identity when nothing changes, so a command over 10,000 cells sharing one interned base writes ONE format object. This capability also gates every format command, `handle.applyFormat` / `setBorders`, the `formats` / `noBorders` paste modes and conditional formats. |
110
114
  | `getCellFormula(row, col)` | **Optional capability** (v0.8) — the cell's formula source, which MUST start with `=` (any other string is a `RangeError` — fail-fast like `findDataEdge`); `undefined` = no formula. Declaring it makes the editor open the formula source instead of `getCellText` (Excel parity), enables **reference pointing** (see Keymap), `fillDown` / `fillRight` / `fillSelection` and the fill handle shift relative references (`shiftFormula`), self-calc commands wrap formulas instead of rewriting values, copy writes an origin-relative R1C1 `f` per cell and paste resolves it at the target (`fromR1C1`), and the `formulas` paste mode becomes available. Without it a `"="`-prefixed text is just text — the package never sniffs `=`. |
111
115
  | `getCellNote(row, col)` | **Optional capability** (v0.8) — the note text or `undefined`. Unlocks the `data-aqsp-note` indicator, the hover note tip, the note popover (`Shift+F2` / `insertNote`), `handle.setNote` and the `commentsAndNotes` paste aspect. |
112
116
  | `getCellComments(row, col)` | **Optional capability** (v0.8) — the thread as `readonly CellComment[]` (`{ id, author, text, createdAt }`, all stamped by you) or `undefined`; an empty thread shows no indicator. Unlocks `data-aqsp-comment`, the comment popover (`Ctrl+Alt+M` / `insertComment`), `handle.addComment` (sends `[...existing, { text }]` — you mint the id) and the comments paste aspect (pasted comments arrive id-less, all new). |
@@ -115,10 +119,15 @@ The host element must have a resolvable height — the grid fills its parent.
115
119
  | `getConditionalFormats()` | **Optional capability** (v0.8) — the sheet's `ConditionalFormatRule[]` in priority order (`{ id, ranges, condition, style, stopIfTrue? }`; conditions `cellValue` / `text` / `empty` / `notEmpty` / `colorScale` / `customFormula`; style = background / color / bold / italic / underline / strikethrough). **Requires `getCellFormat`** (declaring it alone throws a `RangeError` at mount). Keep the array identity stable and publish changes through `dataVersion`; the grid rebuilds a lazy 2-D 256 × 256 bucket index per (rules identity, render version) and, inside each bucket, a lazily materialised per-column slice, so per-cell cost is O(rules intersecting the cell's column within its 256-row band) — for whole-column rules exactly the rules on that column, whatever the sheet width (a sheet of ≤ 256 columns has a single column band, so the bucket alone would be the whole rule set). Every true rule contributes, an earlier rule wins per property, `stopIfTrue` ends the walk. Pasted rules arrive as `context.conditionalFormats` (`ConditionalFormatRuleInput` with `sourceRuleId` — you mint the new id). |
116
120
  | `getRangeExtrema(range)` / `evaluateFormula(formula, cell)` | **Optional capabilities** (v0.8) consumed by the rules above: `getRangeExtrema` returns `{ min, max }` (or `null` = no numeric value) for a `colorScale` point without `value` — called once per rule range per render version and memoised; `evaluateFormula` receives a `customFormula` verbatim (no reference shifting — you own relative semantics) and a `custom` validation formula at commit, truth = `true` or a finite non-zero number. Undeclared ⇒ the rules that need them are skipped (nothing is substituted). |
117
121
  | `isCellReadOnly(row, col)` | **Optional capability** (v0.10 — the 11th optional member, a non-aspect capability). `true` marks the cell read-only: no edit session can open on it (all six entries, including the touch on-screen-keyboard invitation, refuse **before** any side effect), an already-open session's commit is live re-checked, and every write producer whose target set contains one read-only cell — Delete, paste, cut (both the Ctrl+X arming and the move-time source re-check), fill (diagonal L-legs decomposed exactly), format, self-calc, annotations — refuses the **whole operation** before the adapter is reached (Excel-style, never a per-cell skip). Painted read-only cells carry `aria-readonly="true"` + `data-aqsp-readonly` (no package CSS — style the attribute yourself); refusals fire the single sealed callback `onReadOnlyRejected` (+ opt-in `formatReadOnlyAnnouncement` for the live region) and never reuse `onEditRejected` / `onOperationRejected`. Reading stays legal: copying from read-only cells, filling **from** a read-only source into editable cells, and viewing existing notes / comments / links (content-aware chords open viewer-mode popovers with the commit affordance disabled). The predicate is a **live read** — no memoization; publish changes through `dataVersion` (or an adapter swap) like every other read. It is called with in-grid coordinates only, on gesture paths and for painted cells, so budget O(1) per call without allocating; a throw propagates uncaught (fail-fast, never swallowed). Undeclared ⇒ every cell is editable and the DOM / callbacks are byte-identical to v0.9 (a capability branch, not a fallback). Reference: the `/readonly` demo. |
122
+ | `getCellAttributes(row, col)` | **Optional capability** (v0.12 — the 12th optional member, a non-aspect capability, ADR-32). Returns host-owned `data-*` attributes for one cell (or `null` for none); they are stamped **verbatim** onto the package-owned cell element (`[data-aqsp-cell]`), so host CSS can target cell state, `:hover` and theme tokens without wrapper elements inside the cell — the package never interprets the values. Validated fail-fast at render (RangeError): the return must be a plain record or `null`, keys are lowercase `data-[a-z0-9-]+` only, the package- and engine-emitted cell attributes (`data-aqsp*`, `data-aqvs*`, `data-row`, `data-col`) are reserved, and values must be strings. It is read once per painted cell per slice (the `getCellText` cost budget — keep it O(1)); publish changes through `dataVersion`. Undeclared ⇒ the cell DOM is byte-identical to v0.11. |
118
123
 
119
124
  - **Adapter identity is the sheet identity.** Replacing the adapter object means "sheet switch": selection resets to A1, any in-flight drag, editing session and open popover are terminated (value-discarding, exactly like Excel switching sheets — nothing is ever committed across a swap), an armed
120
125
  **copy** is discarded, and an armed **cut** is kept as a *foreign* clip (`onClipChange({ kind: "cut", range, foreign: true })`, its marquee hidden) so the next `Ctrl+V` on the new sheet is a **cross-sheet move** — the target sheet is written first, then the source sheet is cleared under the same
121
126
  transaction id (see "Cut matching rule").
127
+ - **A count shrink keeps the identity.** When `rowCount` / `colCount` shrink under the SAME adapter (a filter, category or period change removes rows), the package clamps its own coordinates instead of resetting (ADR-33): every selection range, the anchor and the active cell are pulled onto the last row / column (a range entirely beyond the edge collapses onto it; one `onSelectionChange` when anything moved, none otherwise),
128
+ an editing session, a popover or a note tip whose cell left the grid is cancelled / closed (those still inside are kept), an armed clip whose range left the grid is discarded (a foreign cut is untouched), in-flight drags are cancelled, and a shrink to zero rows / columns lands on the empty-grid state. Growth changes nothing. The engine's out-of-range fail-fast stays intact — the package clamps its state before anything is measured.
129
+ Traits to know: the fold fires `onSelectionChange` once from a layout effect (host-driven, not a gesture); closing a popover this way takes the popover's normal close path, which returns focus to the grid; a kept selection / session is index-based — after a filter, row r shows whichever item now occupies row r (your data model decides). A kept editing session whose formula token references a vanished row keeps working — keyboard reference pointing clamps the token-derived cursor onto the grid before it steps.
130
+ A popover closed this way leaves your focus alone while it sits outside the grid (a filter input); only a close from inside the grid returns focus to the sink.
122
131
  - **External writes** (your toolbar Undo, collaborative updates) are published by incrementing the `dataVersion` prop. This re-renders visible cells, rebuilds the conditional-format index, **and cancels any armed clip** — otherwise a stale cut could "move" cells whose content has changed underneath.
123
132
 
124
133
  ## Editing, Undo, and rejection
@@ -245,8 +254,8 @@ undo utilities.
245
254
  Idle fills, the fill handle, paste and self-calc are not validated — it is a commit-time gate, as in Excel.
246
255
 
247
256
  An unchanged commit (opening the editor and leaving without changing the text) never reaches `applyEdits`, so a plain focus-out on a
248
- read-only sheet produces no rejection at all. The demo's `demo/src/components/editRejection.ts` (`describeEditRejection`) is a
249
- reference for wording a notice from this context.
257
+ read-only sheet produces no rejection at all. The demo's `demo/src/components/editRejection.ts` (`describeEditRejection(rejection, routeLabel, locale)`) is a
258
+ reference for wording a notice from this context in English or Japanese.
250
259
 
251
260
  ## Operation limits (`maxOperationCells`, default 100,000)
252
261
 
@@ -441,16 +450,18 @@ The grid renders cells as a **non-focusable virtualized surface** with a hidden
441
450
  keyboard input — this is what makes IME composition, clipboard events, and 16M-cell virtualization coexist. The trade-offs:
442
451
 
443
452
  - Screen readers do not walk cells as a native table/grid. Instead, cell and selection changes can be **announced** through a
444
- debounced `aria-live="polite"` region — **strictly opt-in**: the package ships no built-in strings, and the live region is
453
+ debounced `aria-live="polite"` region — **strictly opt-in**: the package ships no built-in announcement strings (the `locale` catalogs cover UI chrome only), and the live region is
445
454
  rendered only when you provide `formatCellAnnouncement` and/or `formatSelectionAnnouncement`. Omit both and the grid is
446
455
  silent to assistive technology beyond its labels. The cell-announcement context carries both `text` (the raw canonical
447
456
  `getCellText`) and `displayText` (the formatted string a sighted user sees — identical to `text` on unformatted cells),
448
457
  so a formatter can announce `"87.0%"` rather than `"0.87"`.
449
- - `labels.sinkAriaLabel` labels the input surface (its default explains the Escape hand-off in English); `labels.rootAriaLabel`
450
- labels the grid container (default `"Spreadsheet"`). Both live in the `labels` prop (v0.8 — the former `sinkAriaLabel` /
451
- `rootAriaLabel` props were folded into it, together with the 21 popover / indicator strings; `DEFAULT_LABELS` is exported and
452
- an omitted or `undefined` key keeps its default). **Localize both together**, and a replacement `sinkAriaLabel` **must keep the
453
- Escape-exit notice** — the word `Escape` must appear, otherwise the grid throws a `RangeError` while rendering: Escape moves
458
+ - `labels.sinkAriaLabel` labels the input surface (its catalog value explains the Escape hand-off — in English by default, in
459
+ Japanese under `locale="ja"`); `labels.rootAriaLabel` labels the grid container (`"Spreadsheet"` / `"スプレッドシート"`). Both
460
+ live in the `labels` prop (v0.8 — the former `sinkAriaLabel` / `rootAriaLabel` props were folded into it; together with the 19
461
+ popover strings they are the 21 package-owned keys, and the 7 engine keys name the grid scrollbars' arrows, edge pills and
462
+ empty state). The `locale` prop picks the catalog (`SPREADSHEET_LABEL_CATALOGS[locale]`), and an omitted or `undefined` key
463
+ keeps that catalog's value. Switching `locale` localizes both names together; if you override them, **override both**, and a
464
+ replacement `sinkAriaLabel` **must keep the Escape-exit notice** — the word `Escape` must appear, otherwise the grid throws a `RangeError` while rendering: Escape moves
454
465
  focus from the sink to the root, and without the notice (or with an unlabeled landing point) screen-reader users are stranded
455
466
  mid-grid. Popovers are keyboard-complete (`role="dialog"` editors with an autofocused field, `role="tooltip"` note tip wired to
456
467
  the sink's `aria-describedby` while its cell is active — a while-invariant, re-evaluated as arrow keys move the active cell in
@@ -678,6 +689,37 @@ Keymap notes:
678
689
  never change while pointing; the corner, resize strips, fill handle, scrollbar bands and zoom dead space are consumed without
679
690
  inserting anything. A grid-exit focus-out commits the partial formula as typed.
680
691
 
692
+ ## Localization
693
+
694
+ The grid's own UI chrome ships in English (`"en"`, the default) and Japanese (`"ja"`). One prop selects it:
695
+
696
+ ```tsx
697
+ <Spreadsheet adapter={adapter} locale="ja" />
698
+ ```
699
+
700
+ | Surface | `en` | `ja` |
701
+ | --- | --- | --- |
702
+ | Root / sink accessible names (`rootAriaLabel` / `sinkAriaLabel`) | `Spreadsheet` / `Spreadsheet grid. Press Escape to move focus out of the grid.` | `スプレッドシート` / `スプレッドシートのグリッド。Escape キーでグリッドの外へフォーカスを移動します。` |
703
+ | Popovers (note / comment / link / list / message, e.g. `noteTitle`, `commentCount(2)`, `messageTitle`, `popoverClose`) | `Note`, `2 comments`, `Data validation`, `Close` | `メモ`, `2 件のコメント`, `データの入力規則`, `閉じる` |
704
+ | Grid scrollbars (engine keys `scrollUp` … `noItems`) | `Scroll up`, `Scroll left`, `No items` | `上へスクロール`, `左へスクロール`, `項目がありません` |
705
+ | Built-in validation messages (rules without `errorMessage`) | `Value must be a number greater than 5.` | `5 より大きい数値を入力してください。` |
706
+
707
+ - **Catalogs**: `SPREADSHEET_LABEL_CATALOGS.en` / `.ja` are frozen and exported; the 7 scrollbar keys are the values of
708
+ `VIRTUAL_SCROLL_LABEL_CATALOGS` from `@aiquants/virtualscroll`, reused rather than restated. `labels` overrides any subset on top
709
+ of the active catalog (`resolveLabels(locale, overrides)` is the same fold, exported for hosts that render their own chrome).
710
+ - **No language negotiation**: `locale` accepts exactly the supported values — `VIRTUAL_SCROLL_LOCALES` from the peer
711
+ `@aiquants/virtualscroll` (not re-exported here). `"ja-JP"`, `"EN"`, `""`, `null` or an unknown language throw a `RangeError`
712
+ during render. Map `navigator.language` (or your router / user setting) yourself, e.g.
713
+ `const locale = navigator.language.toLowerCase().startsWith("ja") ? "ja" : "en"`.
714
+ - **Two different locales**: `locale` is the UI chrome language; `formats.locale` is the BCP-47 value-formatting locale used by
715
+ `formatCurrency` and HTML paste recovery. Cell values keep formatting per their `getCellFormat` regardless of `locale`.
716
+ - **No `lang` attribute is stamped**: the host owns the document's and ancestors' language (`<html lang>`).
717
+ - **Live switching**: changing `locale` re-words the chrome in place — selection, the edit session, scroll position and the grid
718
+ DOM survive. An already open validation message popover keeps the language it was presented in until it is shown again.
719
+ - **Your own strings stay yours**: announcement formatters (`formatCellAnnouncement` / `formatSelectionAnnouncement` /
720
+ `formatCommandAnnouncement` / `formatReadOnlyAnnouncement`), rule `errorMessage` / `inputMessage`, and cell data are never
721
+ translated by the package.
722
+
681
723
  ## Props reference
682
724
 
683
725
  | Prop | Type (abridged) | Notes |
@@ -689,7 +731,7 @@ Keymap notes:
689
731
  | `frozenRows` | `number` | Freezes the first N rows at the top edge (v0.6; default 0, LIVE — the row-axis twin of `frozenColumns`). Engine-validated (integer in [0, 128] — `RangeError` otherwise; values beyond `rowCount` freeze every row). Frozen rows stay visible at any vertical scroll; keyboard ensure-visible treats them as vertical no-ops. |
690
732
  | `frozenTrailingColumns` | `number` | Freezes the **last** N columns at the right edge (v0.9; default 0, LIVE). Engine-validated pass-through, no second validation layer (integer in [0, 128] — `RangeError` otherwise); the effective count is clamped so the leading freeze wins the count space (`min(N, colCount − effectiveFrozenColumns)`). Trailing columns stay visible at any horizontal scroll — **except** when the leading + trailing extent exceeds the viewport: occluded trailing cells cannot be scrolled into view and keyboard focus draws no visible frame there (registered overlap consequence). `scrollToCell` / keyboard ensure-visible treat trailing targets as horizontal no-ops. An in-flight change of any of the four freeze props cancels every drag without restore. |
691
733
  | `frozenTrailingRows` | `number` | Freezes the **last** N rows at the bottom edge (v0.9; default 0, LIVE — the row-axis twin of `frozenTrailingColumns`; a pinned totals row is the canonical use). Same engine validation and leading-wins clamp; trailing rows stay visible at any vertical scroll (same overlap exception); keyboard ensure-visible treats them as vertical no-ops. |
692
- | `renderCell` | `(ctx: RenderCellContext) => ReactNode` — `RenderCellContext = { row; col; text }` (exported) | Custom cell rendering. **Must be identity-stable** (`useCallback`) — an inline arrow defeats the cell memo and re-renders every visible cell each pass. |
734
+ | `renderCell` | `(ctx: RenderCellContext) => ReactNode` — `RenderCellContext = { row; col; text; displayText }` (exported) | Custom cell rendering. **Must be identity-stable** (`useCallback`) — an inline arrow defeats the cell memo and re-renders every visible cell each pass. **Per-cell opt-out** (v0.12 — ADR-32): return `undefined` and that one cell falls back to the package default rendering, value-format engine included (only `undefined` opts out — `null`, `false` and any other ReactNode are custom output, i.e. an empty custom cell). `ctx.text` is the raw canonical string; `ctx.displayText` is the value-format display — the exact string the default rendering, announcements, the HTML clipboard and autofit share, so a custom cell that renders it can never drift from what gets copied. Autofit stays a whole-grid no-op while `renderCell` is set. |
693
735
  | `maxOperationCells` | `number` | Bulk-operation ceiling (default 100,000). Row fan-out resize gates at 1/10 of it and autofit at 1/4 — see "Operation limits". |
694
736
  | `enableResizeUI` | `boolean` | Default `true`. `false` removes resize strips **and unhide markers** — hidden tracks then have no in-package restore path (imperative API only). |
695
737
  | `zoom` / `defaultZoom` / `onZoomChange` | `number` / `number` / `(zoom: number) => void` | Package zoom (v0.3): visual transform scale, logic stays in logical px. Controlled (`zoom`) or uncontrolled (`defaultZoom`, default 1). Gestures — Alt+Wheel and Ctrl+Alt+`=` / `+` / `-` / `0` (reset; the four keys match the produced character, Shift-agnostic; AltGraph-state chords are never claimed — legacy-shape defense in depth, see Keymap notes) — request via `onZoomChange`; Ctrl+Wheel is deliberately unbound (the browser owns it). Clamped to [0.25, 4]; a non-finite value (NaN / ±Infinity) throws a `RangeError` (fail-fast — no silent substitution). |
@@ -702,17 +744,18 @@ Keymap notes:
702
744
  | `onUndoRequest` / `onRedoRequest` | `() => void` | `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` relays. |
703
745
  | `formatCellAnnouncement` / `formatSelectionAnnouncement` | formatter | Opt-in screen-reader announcements (see above). |
704
746
  | `formatCommandAnnouncement` | `(ctx: { command: SpreadsheetCommand; state: boolean \| null }) => string` | Opt-in (v0.8) live-region text after a successful format command; `state` is the toggle's post-state (`bold` / `italic` / `underline` / `strikethrough` / colour toggles), `null` for non-toggle commands. Also makes the live region render on its own. |
705
- | `labels` | `SpreadsheetLabelOverrides` (any subset of the 25 `SpreadsheetLabels` keys) | v0.8 — replaces the `sinkAriaLabel` / `rootAriaLabel` props (folded in) and names every popover / indicator string (`noteTitle`, `noteSave`, `noteEscapeHint`, `noteRejectedHint`, `noteViewerHint` (v0.10 — the note hint in the read-only viewer mode, which never promises a save), `commentTitle`, `commentReply`, `commentPost`, `commentCount` (string or `(n) => string`), `linkTitle`, `linkTextField`, `linkUrlField`, `linkOpen`, `linkSave`, `listTitle`, `listEmpty`, `listMore` (string or `(n) => string` — the validation-list picker mounts at most 500 options and announces the hidden-match count on this row), `messageTitle`, `validationRejected`, `popoverClose`, `indicatorNote`, `indicatorComment`, `indicatorInvalid`). Omitted / `undefined` keys keep the English `DEFAULT_LABELS`; `sinkAriaLabel` must mention `Escape` (`RangeError` during render otherwise). |
747
+ | `labels` | `SpreadsheetLabelOverrides` (any subset of the 28 `SpreadsheetLabels` keys) | v0.8 — replaces the `sinkAriaLabel` / `rootAriaLabel` props (folded in) and names every UI chrome string: the 7 engine keys shared with `@aiquants/virtualscroll` (`scrollUp`, `scrollDown`, `scrollLeft`, `scrollRight`, `scrollToTop`, `scrollToBottom`, `noItems` — they reach the grid scrollbars) and the 21 package keys (`sinkAriaLabel`, `rootAriaLabel`, `noteTitle`, `noteSave`, `noteEscapeHint`, `noteRejectedHint`, `noteViewerHint` (v0.10 — the note hint in the read-only viewer mode, which never promises a save), `commentTitle`, `commentReply`, `commentPost`, `commentCount` (string or `(n) => string`), `linkTitle`, `linkTextField`, `linkUrlField`, `linkOpen`, `linkSave`, `listTitle`, `listEmpty`, `listMore` (string or `(n) => string` — the validation-list picker mounts at most 500 options and announces the hidden-match count on this row), `messageTitle`, `popoverClose`). Omitted / `undefined` keys keep the value of the `locale` catalog (`SPREADSHEET_LABEL_CATALOGS[locale]`); an unknown key (including the removed `validationRejected` / `indicatorNote` / `indicatorComment` / `indicatorInvalid`) or a blank / non-string value throws a `RangeError` during render; `sinkAriaLabel` must mention `Escape` (`RangeError` during render otherwise). |
748
+ | `locale` | `SpreadsheetLocale` (`"en" \| "ja"`) | v0.13 — the UI chrome language, default `"en"`: selects the label catalog, the built-in validation messages (`onValidationRejected`'s `message` and the message popover) and the grid scrollbars' arrow names / empty state. Any other value (`"fr"`, `"ja-JP"`, `"EN"`, `""`, `null`) throws a `RangeError` during render, even on an empty grid — there is no language negotiation. Unrelated to `formats.locale`. See "Localization". |
706
749
  | `shortcuts` | `ShortcutOverrides` = `{ [command]?: readonly string[] \| null }` | v0.8 — rebinds command chords (array = replace the default list, `null` = unbind); validated during render with `RangeError`s (see "Command shortcuts"). Identity-stable — consulted through a ref. |
707
750
  | `colors` | `{ fill?: readonly string[]; font?: readonly string[] }` | v0.8 — the five `#rrggbb` slots of `fillColorCustom1..5` / `fontColorCustom1..5` (exactly 5 six-digit hex strings per axis, `RangeError` otherwise); an omitted axis uses `DEFAULT_CUSTOM_COLORS` (`#FF0000` / `#00FF00` / `#0000FF` / `#FFFF00` / `#FF00FF`, exported). |
708
- | `formats` | `{ currency: string; locale?: string }` | v0.8 — the host currency (ISO code) and rendering locale for `formatCurrency` and for recovering bare currency symbols (`$`, `¥`) in pasted Excel / Sheets HTML; undeclared ⇒ `formatCurrency` passes through and bare symbols are not recovered. |
751
+ | `formats` | `{ currency: string; locale?: string }` | v0.8 — the host currency (ISO code) and rendering locale for `formatCurrency` and for recovering bare currency symbols (`$`, `¥`) in pasted Excel / Sheets HTML; undeclared ⇒ `formatCurrency` passes through and bare symbols are not recovered. `formats.locale` is a BCP-47 value-formatting locale (`"ja-JP"`, …) and is independent of the UI-chrome `locale` prop — changing one never changes the other. |
709
752
  | `highlight` / `defaultHighlight` / `onHighlightChange` | `HighlightState` = `{ row: boolean; column: boolean; intersection: boolean }` / same / `(h) => void` | v0.8 — active row / column / intersection highlight bands, controlled (`highlight` + `onHighlightChange`) or uncontrolled (`defaultHighlight`), the same two-phase contract as `zoom`. Default all off ⇒ zero band nodes (v0.7 DOM identity). Toggled by the `toggle*Highlight` commands. When exactly ONE family (row XOR column) is on and `intersection` is off, the cross node lights up tinted with that family's colour so the band runs unbroken through the selection (sheets-enhancer parity — with both families on the hole is kept). A whole-row / whole-column selection (row / column header press, Shift+Space / Ctrl+Space) draws no row / column bands — only the intersection — so the cross-axis band never tints the rest of the sheet. |
710
753
  | `highlightColors` | `HighlightColors` = `{ row; column; intersection; opacity }` | v0.8 — identity-stable band palette written to `--aqsp-band-row` / `-column` / `-intersection` / `-opacity` on the overlay in a layout effect; each colour must parse (`normalizeColor`), `opacity` must be a finite number in [0, 1] (`RangeError` otherwise). Undeclared ⇒ nothing is written while every band is off and `DEFAULT_HIGHLIGHT_COLORS` (`#0084FF` / `#42FF4F` / `#E1FF00` / 0.2, exported) is written once a band turns on. |
711
754
  | `popovers` | `PopoverOverrides` = `{ [kind]?: boolean }` (`note` / `note-tip` / `comment` / `link` / `list` / `message`) | v0.8 — `false` disables a kind: its commands pass through and `handle.openPopover(kind)` throws a `RangeError`. The popover layer mounts only when at least one kind is enabled AND its capability is declared. |
712
755
  | `renderPopover` | `(ctx: PopoverContext) => ReactNode` | v0.8 — replaces the default popover components; `ctx.kind` discriminates the six contexts (`NotePopoverContext` … `MessagePopoverContext`, exported) and the defaults (`NotePopover` / `NoteTip` / `CommentPopover` / `LinkPopover` / `ListPopover` / `MessagePopover`) are exported so a renderer can replace one kind and delegate the rest. |
713
756
  | `onClipChange` | `(clip: ClipInfo \| null) => void` | v0.8 — armed clip notice: `{ kind: "copy" \| "cut", range, foreign }` on arm, `null` on discard, `foreign: true` for a cut kept across a sheet switch. Idempotent (no call when nothing changes). |
714
757
  | `onHyperlinkOpen` | `(url: string, cell: CellCoord) => void` | v0.8 — navigation seam for `Alt+Enter`, the link popover's Open button and Ctrl / Meta+click on a link cell; `url` is always a normalised allow-listed href (`normalizeHyperlink` output — an adapter URL outside `http:` / `https:` / `mailto:` / `tel:` never reaches this seam, so `window.open(url)` is safe) (the click is consumed with the selection frozen; Ctrl+Shift stays range-extend); undeclared ⇒ no navigation, no Open button and no pointer gesture. |
715
- | `onValidationRejected` | `(ctx: { cell; rule; text; message }) => void` | v0.8 — a `strict` validation rule refused a changed commit (the adapter was not called; the message popover shows `message`). |
758
+ | `onValidationRejected` | `(ctx: { cell; rule; text; message }) => void` | v0.8 — a `strict` validation rule refused a changed commit (the adapter was not called; the message popover shows `message`). `message` is the rule's `errorMessage` when set (even `""`), otherwise the built-in message in the active `locale`. |
716
759
  | `onPasteSpecialRequest` | `(origin: { kind: "keyboard"; cellRect }) => void` | v0.8 — `Ctrl+Alt+V` asks the host for its Paste Special UI (the active cell's viewport rect in visual px, the `onCellContextMenu` keyboard shape); undeclared ⇒ `Ctrl+Alt+V` is a formats-only paste through the menu path. |
717
760
  | `onClipboardUnavailable` | `(cause: "insecure-origin" \| "permission-denied" \| "empty" \| "unreadable") => void` | v0.8 — the menu path's refusal channel (`handle.paste`, `Ctrl+Alt+V` without a request handler, the `paste*` commands); whenever it fires the paste resolves `"rejected"`. `"unreadable"` also fires on the paste event when the detected source cannot be parsed (the package never slides to another reader). |
718
761
  | `now` | `() => number` | v0.8 — clock seam of `insertDate` / `insertTime` (declared default `Date.now`). |
@@ -720,8 +763,9 @@ Keymap notes:
720
763
  | `onReadOnlyRejected` | `(rejection: ReadOnlyRejection) => void` | v0.10 — the single read-only refusal channel: fired once per refused gesture, the adapter was never called (neither an `EditRejection` nor an operation limit). The edit variant carries the cell and the required `editSession: "none" \| "kept" \| "discarded"` fate; the operation variant carries the explicit 7-literal `op` (`"cut"` has two producers — the Ctrl+X arming refusal and the move-time source re-check) and the producer’s overlap-naive `cellCount`. Never fired when `isCellReadOnly` is undeclared. |
721
764
  | `formatReadOnlyAnnouncement` | `(rejection: ReadOnlyRejection) => string` | v0.10 — opt-in live-region text for a read-only refusal (inherits the 200 ms announce debounce + rAF collapse, so autorepeat / IME refusal storms cannot spam the live region). Omit for silence (no built-in strings). |
722
765
  | `tapScrollCircleOptions` | `ScrollBarTapCircleOptions` | v0.10 — position / enable-disable tuning of the touch tap-scroll circle; an engine-validated pass-through onto VirtualGrid’s `scrollBarOptions.tapScrollCircleOptions` (the engine’s `resolveTapScrollCircleOptions` is the validation SSOT — no second layer here). LIVE prop. |
723
- | `overscanRows` / `overscanCols` | `number` | v0.11 — per-axis overscan pass-through onto VirtualGrid’s `overscanRows` / `overscanCols` (undefined ⇒ the engine default 3). This slack is what absorbs the engine’s one-frame rendering-window lag during fast scrolling — raise it when fast flicks show blank bands (slack per edge = overscan × row height / column width). The engine clamps invalid values silently, so THIS surface fail-fasts: a non-integer or negative value throws a RangeError at render. Cost multiplies across the two axes (bounded by the engine cell cap). LIVE props. |
724
- | `background` | `ReactNode` | v0.11 — background layer rendered behind the scroll-band rows inside the engine content element (the VirtualGrid `background` pass-through). VIEWPORT-PINNED: the engine translates only its row wrapper, so this layer does not follow the scroll offset — periodic row ruling placed here slides against the real rows. Suited to a static “content still arriving” texture for the overscan-exhausted band, not to row-aligned gridlines. LIVE prop. |
766
+ | `overscanRows` / `overscanCols` | `number` | v0.11 — per-axis overscan pass-through onto VirtualGrid’s `overscanRows` / `overscanCols` (undefined ⇒ the engine default 3). This slack is what absorbs the engine’s one-frame rendering-window lag during fast scrolling — raise it when fast flicks show blank bands. Overscan counts rows / columns (hidden 0-size tracks consume slots too), so the px slack per edge is overscan × row height / column width only where tracks are uniform and visible. The engine does not validate overscan (a negative value silently shrinks the rendering window), so THIS surface fail-fasts: a non-integer, negative or non-finite value throws a RangeError at render. Cost multiplies across the two axes, and the engine truncates rather than bounds: a window beyond its per-axis row budget or its total cell cap keeps the leading side and drops the trailing side — the row-budget case has no callback (the engine only logs a warning), the cell-cap case reports dropped columns through `onRenderTruncated`. Keep overscan to the slack you need (hundreds per side approach the budget on tall viewports). LIVE props. |
767
+ | `background` | `ReactNode` | v0.11 — background layer rendered behind the scroll-band rows inside the engine content element (the VirtualGrid `background` pass-through). VIEWPORT-PINNED: the engine translates only its row wrapper, so this layer does not follow the scroll offset — periodic row ruling placed here slides against the real rows. Suited to a static “content still arriving” texture for the overscan-exhausted band, not to row-aligned gridlines. It spans the full width of the scroll rows (the frozen-column region included — frozen columns live inside the scroll rows) but not the frozen row bands. **Identity-stable** like `renderCell`: an inline element pierces the GridBody memo boundary on every host re-render — hoist it or `useMemo` it. LIVE prop. |
768
+ | `onRenderTruncated` | `(info: { renderedCells: number; droppedCols: number }) => void` | v0.12 — pass-through of the VirtualGrid truncation signal: fires when the engine drops trailing columns to stay under its total cell cap (`MAX_RENDERED_CELLS`); truncation is never silent in the engine, and an oversized `overscanCols` × row span is the typical cause. Undefined ⇒ no callback. |
725
769
  | `testId` / `className` | `string` | Test hook / root class extension. |
726
770
 
727
771
  ## Imperative handle
@@ -782,7 +826,7 @@ importers with the exact same Excel-compatible semantics the grid uses. `enumera
782
826
  `onOperationRejected` op), and `EditRejection` (the `onEditRejected` context — see "Editing, Undo, and rejection") are exported for
783
827
  consumer-side annotations.
784
828
 
785
- v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 39 values and 97 types in total — every named type an exported declaration reaches is exported too, so nothing has to be extracted with `Awaited<ReturnType<>>` / `Extract<>`; `src/index.spec.ts` pins it):
829
+ v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 40 values and 98 types in total — every named type an exported declaration reaches is exported too, so nothing has to be extracted with `Awaited<ReturnType<>>` / `Extract<>`; `src/index.spec.ts` pins it):
786
830
 
787
831
  - **Clip payload** — `serializeClipPayload(payload)` / `parseClipPayload(json)` (the lossless wire format `{ v: 1, rows, cols, clipId, formats, validations, cells, colWidths?, rowHeights?, wholeRows, wholeCols, conditionalFormats }`; the parser is total and returns `null` on ANY malformed input, the serializer validates the same way and throws a `RangeError`),
788
832
  `detectClipSource(flavours, channel)` (`"aqsp" | "sheets" | "excel" | "html" | "tsv" | "none"` — the ADR-14 detection over `{ types, getData }`), and the types `ClipPayload` / `ClipCell` / `ClipComment` / `ClipSource` / `ClipInfo` / `ClipFlavours` / `ClipChannel` (the `detectClipSource` parameter types).
@@ -790,10 +834,11 @@ v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/i
790
834
  before every allocation, so the reader never holds more than `maxCells` cells — and rows are pulled lazily through the sibling links, so the stop also ends the row walk), `{ outcome: "none" }` when there is no table; `maxCells` must be a positive integer or the call throws a `RangeError` before any `DOMParser` is built; its parameter types `HtmlTableKind` / `NumberFormatDecodeOptions`).
791
835
  - **Formats** — `canonicalizeFormat` / `mergeFormat` / `formatEquals`, `BORDER_DEFAULT_COLOR`, and the types `CellFormat` / `CellFormatPatch` / `CellBorder` / `CellBorders` / `CellBorderStyle` / `CellVerticalAlign` (plus the v0.7 `CellAlign` / `CellValueFormat` / `formatCellValue`).
792
836
  - **Formulas** — `shiftFormula(formula, dRow, dCol)` (relative references only, `#REF!` below zero), `toR1C1(formula, origin)` / `fromR1C1(r1c1, origin)` (the clip's origin-relative formula form, Google Sheets spelling `R[-1]C[0]`).
793
- - **Annotations and validation** — `normalizeHyperlink(text)` (`http:` / `https:` / `mailto:` / `tel:` only, WHATWG-serialised href or `null`), `validateCellText(rule, text, evaluate?)` with the types `DataValidationRule` (its base `ValidationRuleBase`, the seven members `ListValidationRule` / `NumberValidationRule` / `TextLengthValidationRule` / `DateValidationRule` / `TextValidationRule` / `CheckboxValidationRule` / `CustomValidationRule`,
837
+ - **Annotations and validation** — `normalizeHyperlink(text)` (`http:` / `https:` / `mailto:` / `tel:` only, WHATWG-serialised href or `null`), `validateCellText(rule, text, locale, evaluate?)` (`locale` is required — `"en"` or `"ja"`, anything else including `undefined` throws a `RangeError` before the verdict; the built-in message follows `locale`, while `errorMessage` — even `""` — always wins) with the types `DataValidationRule` (its base `ValidationRuleBase`, the seven members `ListValidationRule` / `NumberValidationRule` /
838
+ `TextLengthValidationRule` / `DateValidationRule` / `TextValidationRule` / `CheckboxValidationRule` / `CustomValidationRule`,
794
839
  the op vocabularies `ValidationCompareOp` / `ValidationTextOp` and the evaluator `ValidationEvaluate`) / `ValidationVerdict` / `CellComment` / `CellCommentInput` / `CellPatch` / `CellEdit` / `EditSource` / `EditContext` / `PlainEditContext` / `PasteEditContext`.
795
840
  - **Conditional formats** — the types `ConditionalFormatRule` / `ConditionalFormatRuleInput` / `ConditionalFormatStyle` / `CfCondition` (its six members `CellValueCondition` / `TextCondition` / `EmptyCondition` / `NotEmptyCondition` / `ColorScaleCondition` / `CustomFormulaCondition` and the `text` op vocabulary `CfTextOp`) / `CompareOp` / `ScalePoint` / `RangeExtrema` / `FormulaValue`.
796
- - **Commands and labels** — `DEFAULT_SHORTCUTS`, `SPREADSHEET_RESERVED_SHORTCUTS`, `parseChord`, `DEFAULT_CUSTOM_COLORS`, `DEFAULT_LABELS`, `resolveLabel(labels, key, n?)`, and the types `SpreadsheetCommand` / `ShortcutMap` / `ShortcutOverrides` / `ParsedChord` / `SpreadsheetLabels` / `SpreadsheetLabelOverrides` / `BorderPreset`.
841
+ - **Commands and labels** — `DEFAULT_SHORTCUTS`, `SPREADSHEET_RESERVED_SHORTCUTS`, `parseChord`, `DEFAULT_CUSTOM_COLORS`, `SPREADSHEET_LABEL_CATALOGS` (the frozen `en` / `ja` label catalogs), `resolveLabels(locale, overrides)` (the single label fold — the same validation the `labels` prop gets), `resolveLabel(labels, key, n?)`, and the types `SpreadsheetCommand` / `ShortcutMap` / `ShortcutOverrides` / `ParsedChord` / `SpreadsheetLabels` / `SpreadsheetLabelOverrides` / `SpreadsheetLocale` / `BorderPreset`.
797
842
  - **Paste Special** — the types `PasteMode` / `PasteOperation` / `PasteOptions` and the `handle.paste` outcome `PasteOutcome` (`"applied" | "empty" | "rejected"`).
798
843
  - **Popovers and bands** — the six default components `NotePopover` / `NoteTip` / `CommentPopover` / `LinkPopover` / `ListPopover` / `MessagePopover` (with their `*Props`), the types `PopoverKind` / `PopoverOverrides` / `PopoverContext` and its six members (their `anchorRect` is a `PopoverRect`), `DEFAULT_HIGHLIGHT_COLORS` and the types `HighlightState` / `HighlightColors`.
799
844
 
@@ -885,7 +930,12 @@ The `/utils` demo route exercises the v0.1 toolkit live; the `/styles` route dri
885
930
  ## Demo (feature showcase, doubles as the E2E fixture)
886
931
 
887
932
  Run `pnpm run demo:dev` from a source checkout (the `demo/` app is not in the npm tarball) and open
888
- `http://localhost:5179`. Ten routes, each an in-header-nav page AND an E2E fixture (`tests/e2e/`):
933
+ `http://localhost:5179`. Ten routes, each an in-header-nav page AND an E2E fixture (`tests/e2e/`). The demo UI is English by
934
+ default; `?lang=ja` (or the language switcher `[data-demo-locale-select]` at the end of the nav) switches every route's chrome and
935
+ passes `locale` to every `<Spreadsheet>` without a reload — the URL keeps `?lang`, nav links carry it, `<html lang>` and the tab
936
+ title follow, and an unsupported `?lang` value fails fast with a `RangeError` (blank page). Sample cell data (seeds, fixtures, the
937
+ read-only store) is locale-invariant and stays Japanese in both languages — it is the fixture under test. `tests/e2e/i18n.spec.ts`
938
+ covers the `?lang=ja` path and the live switch; every other E2E spec runs the English default:
889
939
 
890
940
  | Route | What you experience |
891
941
  | --- | --- |
@@ -896,7 +946,7 @@ Run `pnpm run demo:dev` from a source checkout (the `demo/` app is not in the np
896
946
  | `/styles` | The v0.8 rich cells (`tests/e2e/styles.spec.ts`): a 300 × 26 sheet whose store declares all eight capabilities (a mini formula evaluator for `=A1+B1` / `SUM` / `AVERAGE` feeds `evaluateFormula` and the computed `getCellText`, `getCellFormula` returns the source), a patch-applying store with per-transaction inverse-patch Undo (a cross-sheet cut-move folds into one entry) and consumer-minted comment ids, a format toolbar driven by `handle.executeCommand` (every button keeps focus in the grid), colour pickers, border / number-format presets, link / note / comment / validation / conditional-format editors, a Paste Special menu opened by `onPasteSpecialRequest` (Excel accelerators — modes `A`ll / `F`ormulas / `V`alues / `T` formats / `C`omments and notes / `N` validation / `X` all except borders / `W`idths / `R` conditional formats, operations `O` none / `D` add / `S` subtract / `M` multiply / `I` divide, flags `B` skip blanks / `E` transpose / `J` inject formats → `handle.paste`), highlight-band settings, a shortcut rebinding table with reserved-chord warnings, a clip-info chip (`onClipChange`), a Sheet A / B toggle for cross-sheet paste and cut-move, and Excel / Sheets fixture buttons that write real interop HTML to the clipboard — and, since v0.9, the trailing freeze live by default: `frozenTrailingRows={1}` / `frozenTrailingColumns={1}` alongside the leading 1×1 (this nine-quadrant state is the ambient every `/styles` budget number is certified on) pin the sheet's grand-total row (the last row's `SUM` / `AVERAGE`, bold at the base font) to the bottom edge, with a `data-demo-trailing` checkbox to release it |
897
947
  | `/huge` | The v0.4 deep-reach proof: 1,000,000 × 10,000,000 on a pure functional adapter — Ctrl+→ (and End) reach the rightmost column 9,999,999 and Ctrl+↓ the last row 999,999 in one press (uniform-block Excel semantics, unified with the `/` route; ±1px cell placement), a "deep jump" button (`scrollToCell` to the interior column 5,000,000 ≈ 5 億 px, past the 2^25 wall), zoom ×4 holds ±1 logical px — plus runtime freeze toggles (0 ↔ 3 frozen rows above 0 ↔ 3 frozen columns), a context menu (right-click / Shift+F10) that opens at static frozen-cell coordinates even mid-deep-scroll, and the drag-freshness and performance-budget E2E gates — plus the v0.9 trailing-freeze controls consumed by `tests/e2e/trailing-freeze.spec.ts`: trailing freeze toggles for 0 ↔ 2 last rows / columns (`data-demo-trailing-row-toggle` / `data-demo-trailing-col-toggle` — deliberately not the leading toggles' 0 ↔ 3), a row-axis deep jump to the last scroll row `rowCount − T − 1` (`data-demo-deep-jump-row`, reading the live toggle), and an overlap toggle (`data-demo-overlap-toggle` — 16 / 16 frozen rows and 8 / 8 frozen columns exceed the default E2E viewport on both axes) |
898
948
  | `/tune` | Sizing knobs (`defaultRowHeight` / `defaultColumnWidth` / banded per-column widths via the imperative `resizeColumn` mount effect), hide / unhide (drag a boundary to 0, marker drag / dblclick restore, single-column dblclick autofit), a deliberately low LIVE `maxOperationCells` staging every rejection class (limit, row fan-out ÷10, autofit ÷4, bounds via the multi-cell corner recipe), live `enableResizeUI` / `enableFillHandle` opt-outs, an empty-grid staging toggle (rowCount 0 adapter swap), a data-reset button, and the zoom card (v0.3) — the controlled-mode reference wiring (`zoom` + `onZoomChange`, 50–200% select plus every zoom gesture) and the fixture for the zoom E2E gates |
899
- | `/a11y` | Custom Japanese `formatCellAnnouncement` / `formatSelectionAnnouncement` mirrored into visible readouts, overridden accessible names (Escape advisement kept), the WCAG 2.1.2 escape route |
949
+ | `/a11y` | Locale-aware custom `formatCellAnnouncement` / `formatSelectionAnnouncement` (English by default, Japanese under `?lang=ja`) mirrored into visible readouts, per-locale overridden accessible names (Escape advisement kept), the WCAG 2.1.2 escape route |
900
950
  | `/readonly` | The v0.10 read-only cells (`tests/e2e/readonly.spec.ts`): a 200 × 10 sheet whose adapter declares `isCellReadOnly` (one locked COLUMN plus scattered locked cells for mixed selections) alongside note / comment / link / validation reads — the whole-operation refusals live (edit entries, Delete, paste, cut, fill incl. the diagonal legs, annotation chords on empty cells), the content-aware viewers (the locked column carries a comment thread, a note and a hyperlink; their chords open viewer-mode popovers with the commit affordance disabled), a host-side tint on `[data-aqsp-readonly]` (the package ships no CSS), a refusal toast worded per `ReadOnlyRejection` (the three `editSession` fates told apart, the two-producer cut wording), and an `onPasteResolved` two-line log that sits beside the refusal toast so a read-only-refused paste shows BOTH callbacks |
901
951
  | `/utils` | The exported pure functions live: TSV round-trip, paste-expansion rules, column-name converter (incl. its fail-fast `RangeError`), range algebra, and a clipboard inspector card (discloses the raw text/html payload, textContent-rendered) |
902
952