@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.
@@ -1,38 +1,71 @@
1
1
  /**
2
- * @fileoverview UI label vocabulary (v0.8 §1.6 / §4.1.17) — the ONE label bag of the package.
3
- * The v0.7 `sinkAriaLabel` / `rootAriaLabel` props are folded into it (clean cut-over: no alias
4
- * props remain), joined by the popover / indicator strings of the rich-cell UI. Pure, React-free.
2
+ * @fileoverview UI label vocabulary (v0.8 §1.6 / §4.1.17) — the ONE label bag of the package, now
3
+ * per UI chrome locale ("en" default, "ja" second). The v0.7 `sinkAriaLabel` / `rootAriaLabel`
4
+ * props are folded into it (clean cut-over: no alias props remain), joined by the popover strings
5
+ * of the rich-cell UI and by the seven engine chrome strings of `@aiquants/virtualscroll`
6
+ * (scroll arrows, scroll-to-edge pills, "No items"), which are REUSED from
7
+ * `VIRTUAL_SCROLL_LABEL_CATALOGS` and never re-typed here. Pure, React-free.
5
8
  *
6
9
  * Contract (each clause is pinned by labels.spec.ts):
7
- * - `SpreadsheetLabels` is the closed key set; `DEFAULT_LABELS` supplies an English default for
8
- * every key. Count-aware keys are typed `string | ((n: number) => string)`.
9
- * - `resolveLabels(overrides)` is the single fold point: a key takes its override when the
10
- * override is not `undefined` and the default otherwise (exactly the destructuring-default
11
- * semantics the two folded props had in v0.7), then `assertLabels` runs on the result.
12
- * `undefined` overrides resolve to `DEFAULT_LABELS` by identity.
13
- * - `assertLabels`: `sinkAriaLabel` MUST be a string mentioning "Escape" (WCAG 2.1.2 escape-method
10
+ * - `SpreadsheetLocale` is the engine's locale set (`VirtualScrollLocale`); it is the UI chrome
11
+ * language and a different axis from `formats.locale` (BCP-47 value formatting).
12
+ * - `SpreadsheetLabels` is the closed 28-key set (7 engine keys + 21 own keys);
13
+ * `SPREADSHEET_LABEL_CATALOGS` supplies a frozen catalog per locale. Count-aware keys are typed
14
+ * `string | ((n: number) => string)`.
15
+ * - `resolveLabels(locale, overrides)` is the single fold point: the locale is resolved by
16
+ * `resolveVirtualScrollLocale` (undefined ⇒ "en", unknown ⇒ RangeError); `undefined` overrides
17
+ * return the catalog by identity; otherwise the overrides must be a plain object (prototype
18
+ * `Object.prototype` or `null`) read by own properties only, unknown keys are a RangeError, the engine subset is
19
+ * resolved by `resolveVirtualScrollLabels` and an own key takes its override when it is not
20
+ * `undefined`, then `assertLabels` runs on the result, which is frozen.
21
+ * - `assertLabels`: every non-count own key is a non-blank string, a count key is a non-blank
22
+ * string or a function, and `sinkAriaLabel` MUST mention "Escape" (WCAG 2.1.2 escape-method
14
23
  * advisement, spec 00 §10-4) — otherwise a RangeError, thrown at mount by the root.
24
+ * - `pickEngineLabels(labels)` hands the 7 engine keys to the embedded VirtualGrid.
15
25
  * - `resolveLabel(labels, key, n)` is the single read point: a function-valued label receives `n`
16
26
  * (omitting `n` for a function value is a RangeError — never a silent 0), a string is returned
17
27
  * as-is (`n` ignored, so a consumer may override a count key with a plain string).
18
28
  *
19
- * UI ラベル語彙 (v0.8 §1.6 / §4.1.17) — パッケージ唯一のラベル集合。v0.7 の `sinkAriaLabel` /
20
- * `rootAriaLabel` prop を畳み込み (クリーンカットオーバー: 別名 prop は残さない)、リッチセル UI の
21
- * ポップオーバー / インジケータ文言を加える。純関数・React 非依存。
22
- * 契約: `SpreadsheetLabels` は閉じたキー集合、`DEFAULT_LABELS` は全キーの英語既定、件数キーは
23
- * `string | ((n: number) => string)`。`resolveLabels` が唯一の畳込み点 (undefined でない override を
24
- * 採用、それ以外は既定 — 畳み込んだ 2 prop が v0.7 で持っていた分割代入既定と同じ意味論。
25
- * override 未指定は `DEFAULT_LABELS` を identity で返却) で、続けて `assertLabels`
26
- * (`sinkAriaLabel` は "Escape" を含む文字列であること — spec 00 §10-4、違反はマウント時 RangeError)。
27
- * `resolveLabel` が唯一の読み出し点 (関数値は n 必須 — 省略は RangeError、文字列はそのまま)。
29
+ * UI ラベル語彙 (v0.8 §1.6 / §4.1.17) — パッケージ唯一のラベル集合で、UI クロームロケール別
30
+ * ("en" が既定、"ja" が第 2 ロケール)。v0.7 の `sinkAriaLabel` / `rootAriaLabel` prop を畳み込み
31
+ * (クリーンカットオーバー: 別名 prop は残さない)、リッチセル UI のポップオーバー文言と
32
+ * `@aiquants/virtualscroll` の 7 つのエンジンクローム文言 (スクロール矢印、端スクロールピル、
33
+ * "No items") を加える。エンジン文言は `VIRTUAL_SCROLL_LABEL_CATALOGS` を再利用し、ここでは再記述
34
+ * しない。純関数・React 非依存。
35
+ * 契約: `SpreadsheetLocale` はエンジンのロケール集合 (UI クローム言語で、値書式の `formats.locale`
36
+ * とは別軸)。`SpreadsheetLabels` は閉じた 28 キー (エンジン 7 + 固有 21)、`SPREADSHEET_LABEL_CATALOGS`
37
+ * はロケール別の凍結カタログ、件数キーは `string | ((n: number) => string)`。`resolveLabels` が唯一の
38
+ * 畳込み点 (ロケールは `resolveVirtualScrollLocale` で解決 — undefined は "en"、未知は RangeError。
39
+ * override 未指定はカタログを identity で返却。それ以外は override を素の object (プロトタイプが
40
+ * `Object.prototype` か `null`) に限り自身のプロパティだけを読み、未知キーを RangeError、エンジン部分は
41
+ * `resolveVirtualScrollLabels` で解決、固有キーは undefined でない override を採用し、`assertLabels`
42
+ * 検証の後に凍結)。`assertLabels` は固有キーの非空白文字列性 (件数キーは関数も可) と
43
+ * `sinkAriaLabel` の "Escape" 告知 (spec 00 §10-4) を検証し、違反はマウント時 RangeError。
44
+ * `pickEngineLabels` は埋込み VirtualGrid へ渡すエンジン 7 キーの抽出。`resolveLabel` が唯一の
45
+ * 読み出し点 (関数値は n 必須 — 省略は RangeError、文字列はそのまま)。
28
46
  */
29
47
 
48
+ import { resolveVirtualScrollLabels, resolveVirtualScrollLocale, VIRTUAL_SCROLL_LABEL_CATALOGS, VIRTUAL_SCROLL_LABEL_KEYS, type VirtualScrollLabelOverrides, type VirtualScrollLabels, type VirtualScrollLocale } from "@aiquants/virtualscroll"
49
+
50
+ /**
51
+ * The UI chrome language of the spreadsheet ("en" | "ja") — the engine's locale set, so the grid
52
+ * body and the embedded scroll chrome always speak one language. A different axis from
53
+ * `formats.locale`, which is the BCP-47 locale of value formatting (numbers / dates); a BCP-47 tag
54
+ * such as "ja-JP" is NOT a SpreadsheetLocale.
55
+ * スプレッドシートの UI クローム言語 ("en" | "ja") — エンジンのロケール集合そのもので、グリッド本体と
56
+ * 埋込みスクロールクロームが常に同一言語。値書式 (数値 / 日付) の BCP-47 ロケールである
57
+ * `formats.locale` とは別軸 ("ja-JP" のような BCP-47 タグは SpreadsheetLocale ではない)。
58
+ */
59
+ export type SpreadsheetLocale = VirtualScrollLocale
60
+
30
61
  /**
31
- * The closed label vocabulary. Every key has a default in DEFAULT_LABELS; consumers override any
32
- * subset through the `labels` prop (SpreadsheetLabelOverrides).
33
- * 閉じたラベル語彙。全キーが DEFAULT_LABELS に既定を持ち、消費者は `labels` prop で任意の部分集合を上書き。
62
+ * The closed label vocabulary: the 7 engine chrome keys of `@aiquants/virtualscroll` plus the 21
63
+ * own keys of the spreadsheet. Every key has a value in each SPREADSHEET_LABEL_CATALOGS entry;
64
+ * consumers override any subset through the `labels` prop (SpreadsheetLabelOverrides).
65
+ * 閉じたラベル語彙: `@aiquants/virtualscroll` のエンジンクローム 7 キーとスプレッドシート固有の 21 キー。
66
+ * 全キーが SPREADSHEET_LABEL_CATALOGS の各ロケールに値を持ち、消費者は `labels` prop で任意の部分集合を上書き。
34
67
  */
35
- export type SpreadsheetLabels = {
68
+ export type SpreadsheetLabels = VirtualScrollLabels & {
36
69
  /** Accessible name of the sink (grid input receptor); MUST mention "Escape" (spec 00 §10-4). / シンクのアクセシブルネーム ("Escape" 必須)。 */
37
70
  sinkAriaLabel: string
38
71
  /** Accessible name of the root role="group" landing point. / root 着地点のアクセシブルネーム。 */
@@ -73,32 +106,24 @@ export type SpreadsheetLabels = {
73
106
  listMore: string | ((n: number) => string)
74
107
  /** Message popover heading. / メッセージポップオーバーの見出し。 */
75
108
  messageTitle: string
76
- /** Message shown when a strict validation rule rejects the typed value. / 厳格検証で拒否されたときの文言。 */
77
- validationRejected: string
78
109
  /** Close button of every popover. / ポップオーバー共通の閉じるボタン。 */
79
110
  popoverClose: string
80
- /** Accessible label of the note indicator on a cell. / ノートインジケータのラベル。 */
81
- indicatorNote: string
82
- /** Accessible label of the comment indicator on a cell. / コメントインジケータのラベル。 */
83
- indicatorComment: string
84
- /** Accessible label of the invalid-value indicator on a cell. / 無効値インジケータのラベル。 */
85
- indicatorInvalid: string
86
111
  }
87
112
 
88
113
  /**
89
114
  * The `labels` prop shape: any subset of SpreadsheetLabels (an omitted or `undefined` key keeps
90
- * the default). Keys are closed to SpreadsheetLabels.
91
- * `labels` prop の形: SpreadsheetLabels の任意の部分集合 (省略 / undefined は既定のまま)。キーは
92
- * SpreadsheetLabels に閉じる。
115
+ * the catalog value of the chosen locale). Keys are closed to SpreadsheetLabels.
116
+ * `labels` prop の形: SpreadsheetLabels の任意の部分集合 (省略 / undefined は選択ロケールのカタログ値の
117
+ * まま)。キーは SpreadsheetLabels に閉じる。
93
118
  */
94
119
  export type SpreadsheetLabelOverrides = { readonly [K in keyof SpreadsheetLabels]?: SpreadsheetLabels[K] }
95
120
 
96
121
  /**
97
- * Every label key, in declaration order — the iteration source of the fold and the census list
98
- * for docs / tests. Compile-time proof below that it covers EVERY SpreadsheetLabels key.
99
- * 全ラベルキー (宣言順) — 畳込みの走査元と docs / テストの census 表。直下で全キー網羅を型水準で証明。
122
+ * The 21 own (non-engine) keys, in declaration order — the iteration source of the own-key fold and
123
+ * of assertLabels.
124
+ * 固有 (エンジン外) の 21 キー (宣言順) — 固有キー畳込みと assertLabels の走査元。
100
125
  */
101
- export const LABEL_KEYS = [
126
+ const OWN_LABEL_KEYS = [
102
127
  "sinkAriaLabel",
103
128
  "rootAriaLabel",
104
129
  "noteTitle",
@@ -119,93 +144,256 @@ export const LABEL_KEYS = [
119
144
  "listEmpty",
120
145
  "listMore",
121
146
  "messageTitle",
122
- "validationRejected",
123
147
  "popoverClose",
124
- "indicatorNote",
125
- "indicatorComment",
126
- "indicatorInvalid",
127
148
  ] as const satisfies readonly (keyof SpreadsheetLabels)[]
128
149
 
150
+ /**
151
+ * Every label key: the 7 engine keys (VIRTUAL_SCROLL_LABEL_KEYS, in engine order) followed by the
152
+ * 21 own keys — the closed key set that overrides are validated against, and the census list for
153
+ * docs / tests. Compile-time proofs below that it covers EVERY SpreadsheetLabels key and that the
154
+ * own keys never shadow an engine key.
155
+ * 全ラベルキー: エンジン 7 キー (VIRTUAL_SCROLL_LABEL_KEYS、エンジン順) に固有 21 キーを続けたもの —
156
+ * override 検証の閉じたキー集合で docs / テストの census 表。直下で全キー網羅と、固有キーが
157
+ * エンジンキーを覆い隠さないことを型水準で証明。
158
+ */
159
+ export const LABEL_KEYS = [...VIRTUAL_SCROLL_LABEL_KEYS, ...OWN_LABEL_KEYS] as const satisfies readonly (keyof SpreadsheetLabels)[]
160
+
129
161
  /** Compile-time proof that LABEL_KEYS lists EVERY SpreadsheetLabels key (a key added to the type but not to the list would never be folded). / LABEL_KEYS が SpreadsheetLabels の全キーを網羅することの型水準の証明 (型に増えたキーが表に無いと畳込みから漏れる)。 */
130
162
  const labelKeysCoverEveryKey: [Exclude<keyof SpreadsheetLabels, (typeof LABEL_KEYS)[number]>] extends [never] ? true : never = true
131
163
  void labelKeysCoverEveryKey
132
164
 
165
+ /** Compile-time proof that no own key is also an engine key (an overlap would let the own-key fold bypass the engine's validation of that key). / 固有キーがエンジンキーと重ならないことの型水準の証明 (重なると固有キー畳込みがそのキーのエンジン検証を迂回する)。 */
166
+ const ownLabelKeysAreDisjointFromEngine: [Extract<(typeof OWN_LABEL_KEYS)[number], keyof VirtualScrollLabels>] extends [never] ? true : never = true
167
+ void ownLabelKeysAreDisjointFromEngine
168
+
133
169
  /**
134
- * English defaults for every key. `sinkAriaLabel` / `rootAriaLabel` are byte-identical to the
135
- * v0.7 prop defaults (the fold is DOM-identical); `noteEscapeHint` = "Escape saves and closes"
136
- * and its v0.10 viewer counterpart `noteViewerHint` = "Escape closes without saving";
137
- * `commentCount` and `listMore` pluralise ("1 comment" / "n comments", "1 more value" / "n more values"). Frozen.
138
- * 全キーの英語既定。`sinkAriaLabel` / `rootAriaLabel` は v0.7 の prop 既定と byte 同一 (畳込みは
139
- * DOM 恒等)、`noteEscapeHint` = "Escape saves and closes" とその v0.10 ビューア対応
140
- * `noteViewerHint` = "Escape closes without saving"、`commentCount` / `listMore` は単複を区別。凍結済み。
141
- */
142
- export const DEFAULT_LABELS: SpreadsheetLabels = Object.freeze({
143
- sinkAriaLabel: "Spreadsheet grid. Press Escape to move focus out of the grid.",
144
- rootAriaLabel: "Spreadsheet",
145
- noteTitle: "Note",
146
- noteSave: "Save",
147
- noteEscapeHint: "Escape saves and closes",
148
- noteRejectedHint: "The note was rejected. Press Ctrl+Enter to retry or Escape to close without saving",
149
- noteViewerHint: "Escape closes without saving",
150
- commentTitle: "Comments",
151
- commentReply: "Reply",
152
- commentPost: "Post",
153
- commentCount: (n: number): string => (n === 1 ? "1 comment" : `${n} comments`),
154
- linkTitle: "Link",
155
- linkTextField: "Text",
156
- linkUrlField: "URL",
157
- linkOpen: "Open",
158
- linkSave: "Save",
159
- listTitle: "Choose a value",
160
- listEmpty: "No matching values",
161
- listMore: (n: number): string => (n === 1 ? "1 more value — keep typing to narrow" : `${n} more values — keep typing to narrow`),
162
- messageTitle: "Data validation",
163
- validationRejected: "The value does not satisfy this cell's validation rule",
164
- popoverClose: "Close",
165
- indicatorNote: "Has a note",
166
- indicatorComment: "Has comments",
167
- indicatorInvalid: "Invalid value",
170
+ * Per-locale values of the 21 own keys. `en` is byte-identical to the pre-i18n English defaults
171
+ * (`sinkAriaLabel` / `rootAriaLabel` equal the v0.7 prop defaults, so the fold is DOM-identical);
172
+ * `commentCount` / `listMore` pluralise in `en` and use the counter 件 in `ja`. Frozen.
173
+ * 固有 21 キーのロケール別の値。`en` は i18n 化前の英語既定と byte 同一 (`sinkAriaLabel` /
174
+ * `rootAriaLabel` は v0.7 の prop 既定と同じで畳込みは DOM 恒等)、`commentCount` / `listMore` は
175
+ * `en` で単複を区別し `ja` では助数詞「件」。凍結済み。
176
+ */
177
+ const OWN_LABEL_CATALOGS: Readonly<Record<SpreadsheetLocale, Omit<SpreadsheetLabels, keyof VirtualScrollLabels>>> = Object.freeze({
178
+ en: Object.freeze({
179
+ sinkAriaLabel: "Spreadsheet grid. Press Escape to move focus out of the grid.",
180
+ rootAriaLabel: "Spreadsheet",
181
+ noteTitle: "Note",
182
+ noteSave: "Save",
183
+ noteEscapeHint: "Escape saves and closes",
184
+ noteRejectedHint: "The note was rejected. Press Ctrl+Enter to retry or Escape to close without saving",
185
+ noteViewerHint: "Escape closes without saving",
186
+ commentTitle: "Comments",
187
+ commentReply: "Reply",
188
+ commentPost: "Post",
189
+ commentCount: (n: number): string => (n === 1 ? "1 comment" : `${n} comments`),
190
+ linkTitle: "Link",
191
+ linkTextField: "Text",
192
+ linkUrlField: "URL",
193
+ linkOpen: "Open",
194
+ linkSave: "Save",
195
+ listTitle: "Choose a value",
196
+ listEmpty: "No matching values",
197
+ listMore: (n: number): string => (n === 1 ? "1 more value — keep typing to narrow" : `${n} more values — keep typing to narrow`),
198
+ messageTitle: "Data validation",
199
+ popoverClose: "Close",
200
+ }),
201
+ ja: Object.freeze({
202
+ sinkAriaLabel: "スプレッドシートのグリッド。Escape キーでグリッドの外へフォーカスを移動します。",
203
+ rootAriaLabel: "スプレッドシート",
204
+ noteTitle: "メモ",
205
+ noteSave: "保存",
206
+ noteEscapeHint: "Escape で保存して閉じます",
207
+ noteRejectedHint: "メモは拒否されました。Ctrl+Enter で再試行、Escape で保存せずに閉じます",
208
+ noteViewerHint: "Escape で保存せずに閉じます",
209
+ commentTitle: "コメント",
210
+ commentReply: "返信",
211
+ commentPost: "投稿",
212
+ commentCount: (n: number): string => `${n} 件のコメント`,
213
+ linkTitle: "リンク",
214
+ linkTextField: "表示テキスト",
215
+ linkUrlField: "URL",
216
+ linkOpen: "開く",
217
+ linkSave: "保存",
218
+ listTitle: "値を選択",
219
+ listEmpty: "一致する値がありません",
220
+ listMore: (n: number): string => `ほかに ${n} 件 — 入力して絞り込んでください`,
221
+ messageTitle: "データの入力規則",
222
+ popoverClose: "閉じる",
223
+ }),
168
224
  })
169
225
 
170
226
  /**
171
- * Mount-time check of a resolved label bag: `sinkAriaLabel` must be a string that mentions
172
- * "Escape" (case-sensitive — the WCAG 2.1.2 escape-method advisement of spec 00 §10-4). Anything
173
- * else is a consumer configuration error ⇒ RangeError (fail-fast, never silently repaired).
174
- * 解決済みラベル集合のマウント時検証: `sinkAriaLabel` は "Escape" を含む文字列であること
175
- * (大文字小文字を区別 — spec 00 §10-4 の WCAG 2.1.2 脱出告知)。それ以外は消費者の設定誤りとして
176
- * RangeError (fail-fast、黙って修復しない)。
227
+ * Frozen full catalogs per locale (outer record and every entry frozen): each entry is the engine
228
+ * catalog `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]` (reused by value — single source of truth for
229
+ * the 7 chrome strings) overlaid by the own-key catalog of the same locale.
230
+ * ロケール別の凍結済み完全カタログ (外側のレコードと各エントリを凍結): 各エントリはエンジンカタログ
231
+ * `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]` (値を再利用 — 7 クローム文言の単一の真実源) に同ロケールの
232
+ * 固有キーカタログを重ねたもの。
233
+ */
234
+ export const SPREADSHEET_LABEL_CATALOGS: Readonly<Record<SpreadsheetLocale, SpreadsheetLabels>> = Object.freeze({
235
+ en: Object.freeze({ ...VIRTUAL_SCROLL_LABEL_CATALOGS.en, ...OWN_LABEL_CATALOGS.en }),
236
+ ja: Object.freeze({ ...VIRTUAL_SCROLL_LABEL_CATALOGS.ja, ...OWN_LABEL_CATALOGS.ja }),
237
+ })
238
+
239
+ /**
240
+ * Describes a rejected value for a RangeError message without ever throwing: a JSON string for
241
+ * strings, "null" for `null`, otherwise the typeof name. It never calls `String()` /
242
+ * `JSON.stringify` on an arbitrary value, so BigInt, Symbol, function and null-prototype inputs
243
+ * still produce the documented RangeError with an accurate description. The ONE describer of every
244
+ * label and data-validation RangeError message of the package (assertLabels, resolveLabels,
245
+ * validateCellText's locale and rule-shape checks).
246
+ * RangeError メッセージ用の拒否値の説明 (例外を投げない): 文字列は JSON 表現、`null` は "null"、それ以外は
247
+ * typeof 名。任意値へ `String()` / `JSON.stringify` を適用しないため、BigInt・Symbol・関数・null プロトタイプの
248
+ * 入力でも文書どおりの RangeError と正確な説明を維持。パッケージのラベル / データ検証の全 RangeError
249
+ * メッセージ (assertLabels、resolveLabels、validateCellText のロケール検査と規則形状検査) の唯一の説明器。
250
+ *
251
+ * @param value - The rejected value / 拒否された値
252
+ * @returns A printable, non-throwing description / 表示用の説明文字列 (例外なし)
253
+ */
254
+ export const describeLabelValue = (value: unknown): string => {
255
+ if (typeof value === "string") {
256
+ return JSON.stringify(value)
257
+ }
258
+ return value === null ? "null" : typeof value
259
+ }
260
+
261
+ /**
262
+ * Whether a label key is count-aware (`commentCount` / `listMore`), i.e. may hold a function.
263
+ * ラベルキーが件数キー (`commentCount` / `listMore`) — 関数値を許すキー — かどうか。
264
+ *
265
+ * @param key - The own label key / 固有ラベルキー
266
+ * @returns `true` for a count-aware key / 件数キーなら `true`
267
+ */
268
+ const isCountLabelKey = (key: (typeof OWN_LABEL_KEYS)[number]): key is "commentCount" | "listMore" => key === "commentCount" || key === "listMore"
269
+
270
+ /**
271
+ * Mount-time check of a resolved label bag's own keys (the 7 engine keys are validated by
272
+ * `resolveVirtualScrollLabels`): `sinkAriaLabel` must be a string that mentions "Escape"
273
+ * (case-sensitive — the WCAG 2.1.2 escape-method advisement of spec 00 §10-4); every other
274
+ * non-count own key must be a string with non-whitespace content; `commentCount` / `listMore` must
275
+ * be such a string or a function. Anything else is a consumer configuration error ⇒ RangeError
276
+ * naming the key (fail-fast, never silently repaired).
277
+ * 解決済みラベル集合の固有キーのマウント時検証 (エンジン 7 キーは `resolveVirtualScrollLabels` が検証):
278
+ * `sinkAriaLabel` は "Escape" を含む文字列 (大文字小文字を区別 — spec 00 §10-4 の WCAG 2.1.2 脱出告知)、
279
+ * その他の件数以外の固有キーは空白以外を含む文字列、`commentCount` / `listMore` はその文字列か関数で
280
+ * あること。それ以外は消費者の設定誤りとしてキー名入りの RangeError (fail-fast、黙って修復しない)。
281
+ *
282
+ * @param labels - The resolved label bag / 解決済みラベル集合
283
+ * @returns Nothing; returns normally when every own key is valid / なし (全固有キーが妥当なら正常終了)
284
+ * @throws {RangeError} When an own key violates its rule / 固有キーが規則に違反した場合
177
285
  */
178
286
  export const assertLabels = (labels: SpreadsheetLabels): void => {
179
- const sink = labels.sinkAriaLabel
180
- // 文字列以外 (JS 呼び手) と Escape 告知の欠落は同じ設定誤り
287
+ const sink: unknown = labels.sinkAriaLabel
288
+ // 文字列以外 (JS 呼び手) と Escape 告知の欠落は同じ設定誤り — 既存メッセージを保つため汎用検査より先に判定
181
289
  if (typeof sink !== "string" || !sink.includes("Escape")) {
182
- throw new RangeError(`[spreadsheet] labels.sinkAriaLabel must be a string mentioning "Escape" (WCAG 2.1.2 escape advisement, spec 00 §10-4); got ${JSON.stringify(sink)}`)
290
+ throw new RangeError(`[spreadsheet] labels.sinkAriaLabel must be a string mentioning "Escape" (WCAG 2.1.2 escape advisement, spec 00 §10-4); got ${describeLabelValue(sink)}`)
291
+ }
292
+ for (const key of OWN_LABEL_KEYS) {
293
+ const value: unknown = labels[key]
294
+ if (isCountLabelKey(key) && typeof value === "function") {
295
+ continue
296
+ }
297
+ // 空白だけの文字列は描画上は空のアクセシブルネーム / 見出しになるため空文字列と同じ誤り
298
+ if (typeof value !== "string" || value.trim() === "") {
299
+ const expected = isCountLabelKey(key) ? "a non-blank string or a function" : "a non-blank string"
300
+ throw new RangeError(`[spreadsheet] labels.${key} must be ${expected}; got ${describeLabelValue(value)}`)
301
+ }
183
302
  }
184
303
  }
185
304
 
186
- /** Writes one resolved label; generic so the union-typed key of the fold loop type-checks against the per-key value type. / 解決済みラベルを 1 件書く (畳込みループの union キーをキー毎の値型と突き合わせるための総称関数)。 */
187
- const putLabel = <K extends keyof SpreadsheetLabels>(target: SpreadsheetLabels, key: K, value: SpreadsheetLabels[K]): void => {
305
+ /**
306
+ * Copies the engine subset of an overrides object (only the OWN keys of VIRTUAL_SCROLL_LABEL_KEYS
307
+ * whose value is not `undefined` — an inherited property is never read); `undefined` when none is
308
+ * present, so the engine returns its catalog by identity.
309
+ * override のエンジン部分 (VIRTUAL_SCROLL_LABEL_KEYS のうち値が undefined でない自身のキーのみ —
310
+ * 継承プロパティは読まない) の複製。1 つも無ければ `undefined` (エンジンがカタログを identity で返すため)。
311
+ *
312
+ * @param overrides - The consumer's overrides / 消費者の上書き
313
+ * @returns The engine overrides, or `undefined` when none is present / エンジン上書き (無ければ `undefined`)
314
+ */
315
+ const pickEngineOverrides = (overrides: SpreadsheetLabelOverrides): VirtualScrollLabelOverrides | undefined => {
316
+ const picked: { -readonly [K in keyof VirtualScrollLabels]?: VirtualScrollLabels[K] } = {}
317
+ let present = false
318
+ for (const key of VIRTUAL_SCROLL_LABEL_KEYS) {
319
+ // Object.prototype 汚染で継承されたラベルキーを上書きとして拾わない
320
+ const value = Object.hasOwn(overrides, key) ? overrides[key] : undefined
321
+ if (value !== undefined) {
322
+ picked[key] = value
323
+ present = true
324
+ }
325
+ }
326
+ return present ? picked : undefined
327
+ }
328
+
329
+ /**
330
+ * Writes one resolved own label; generic so the union-typed key of the fold loop type-checks
331
+ * against the per-key value type.
332
+ * 解決済みの固有ラベルを 1 件書く (畳込みループの union キーをキー毎の値型と突き合わせるための総称関数)。
333
+ *
334
+ * @param target - The label bag under construction / 構築中のラベル集合
335
+ * @param key - The own key to write / 書き込む固有キー
336
+ * @param value - The value to write / 書き込む値
337
+ * @returns Nothing / なし
338
+ */
339
+ const putLabel = <K extends (typeof OWN_LABEL_KEYS)[number]>(target: SpreadsheetLabels, key: K, value: SpreadsheetLabels[K]): void => {
188
340
  target[key] = value
189
341
  }
190
342
 
191
343
  /**
192
- * The single fold point of the `labels` prop: every key takes the override when it is not
193
- * `undefined`, the default otherwise (destructuring-default semantics of the folded v0.7 props);
194
- * the result is validated by assertLabels and frozen. `undefined` overrides return DEFAULT_LABELS
195
- * by identity. Inputs are never mutated.
196
- * `labels` prop の唯一の畳込み点: 各キーは override が undefined でなければ override、さもなくば
197
- * 既定 (畳み込んだ v0.7 prop の分割代入既定の意味論)。結果は assertLabels で検証し凍結。override
198
- * 未指定は DEFAULT_LABELS を identity で返す。入力は変更しない。
344
+ * The single fold point of the `locale` + `labels` props. The locale is resolved by
345
+ * `resolveVirtualScrollLocale` (undefined ⇒ "en"). `undefined` overrides return
346
+ * SPREADSHEET_LABEL_CATALOGS[locale] by identity. Otherwise: the overrides must be a plain object
347
+ * (prototype `Object.prototype` or `null`) whose own keys all belong to LABEL_KEYS, and only its
348
+ * own properties are read; the engine subset is resolved by `resolveVirtualScrollLabels` (the
349
+ * engine's own validation); an own key takes its override when it is not `undefined`
350
+ * (destructuring-default semantics of the folded v0.7 props) and the catalog value otherwise; the
351
+ * result is validated by assertLabels and frozen. Inputs are never mutated.
352
+ * `locale` + `labels` prop の唯一の畳込み点。ロケールは `resolveVirtualScrollLocale` で解決
353
+ * (undefined は "en")。override 未指定は SPREADSHEET_LABEL_CATALOGS[locale] を identity で返却。
354
+ * それ以外は、override は自身のキーがすべて LABEL_KEYS に属する素の object (プロトタイプが
355
+ * `Object.prototype` か `null`) で、読むのは自身のプロパティのみ。エンジン部分は
356
+ * `resolveVirtualScrollLabels` (エンジン自身の検証) で解決、固有キーは undefined でない override を採用し
357
+ * それ以外はカタログ値 (畳み込んだ v0.7 prop の分割代入既定の意味論)、結果は assertLabels で検証し凍結。
358
+ * 入力は変更しない。
359
+ *
360
+ * @param locale - UI chrome locale, or `undefined` for the default "en" / UI クロームロケール (`undefined` で既定の "en")
361
+ * @param overrides - Per-key overrides, or `undefined` for none / キー単位の上書き (`undefined` で上書きなし)
362
+ * @returns The resolved, frozen label bag / 解決済みの凍結ラベル集合
363
+ * @throws {RangeError} When the locale is unsupported, the overrides are `null` / not an object,
364
+ * the overrides are not a plain object (an array, a class instance, an object inheriting from
365
+ * another object), an own key is not a label key (including the removed validationRejected /
366
+ * indicator* keys), or a value violates its key's rule / ロケールが非対応、override が `null` /
367
+ * 非 object、override が素の object でない (配列・クラスインスタンス・他 object 継承)、自身のキーが
368
+ * ラベルキー外 (削除済みの validationRejected / indicator* を含む)、または値がキーの規則に違反した場合
199
369
  */
200
- export const resolveLabels = (overrides: SpreadsheetLabelOverrides | undefined): SpreadsheetLabels => {
201
- // override なしは既定そのもの (identity 安定 — 消費者の useMemo 再計算でも同一 object)
370
+ export const resolveLabels = (locale: SpreadsheetLocale | undefined, overrides: SpreadsheetLabelOverrides | undefined): SpreadsheetLabels => {
371
+ const resolvedLocale = resolveVirtualScrollLocale(locale)
372
+ // override なしはカタログそのもの (identity 安定 — 消費者の useMemo 再計算でも同一 object)
202
373
  if (overrides === undefined) {
203
- return DEFAULT_LABELS
374
+ return SPREADSHEET_LABEL_CATALOGS[resolvedLocale]
375
+ }
376
+ if (overrides === null || typeof overrides !== "object") {
377
+ throw new RangeError(`[spreadsheet] labels must be an object; got ${describeLabelValue(overrides)}`)
378
+ }
379
+ // 既知キー検査は自身のキーしか見ないため、継承経路を持つ入力 (配列・クラスインスタンス・
380
+ // 他 object 継承) を許すと継承側の綴り誤りが無言で無視される
381
+ const prototype: unknown = Object.getPrototypeOf(overrides)
382
+ if (prototype !== Object.prototype && prototype !== null) {
383
+ throw new RangeError(`[spreadsheet] labels must be a plain object (prototype Object.prototype or null); got ${Array.isArray(overrides) ? "an array" : "an object with a non-plain prototype"}`)
204
384
  }
205
- const resolved: SpreadsheetLabels = { ...DEFAULT_LABELS }
206
- // 明示 undefined は「未指定」= 既定のまま (分割代入既定と同じ)
207
- for (const key of LABEL_KEYS) {
208
- const value = overrides[key]
385
+ // 未知キーを黙って捨てると削除済みキーや綴り誤りの設定が無言で効かなくなる
386
+ for (const key of Object.keys(overrides)) {
387
+ if (!(LABEL_KEYS as readonly string[]).includes(key)) {
388
+ throw new RangeError(`[spreadsheet] labels.${key} is not a known label key; expected one of ${LABEL_KEYS.join(", ")}`)
389
+ }
390
+ }
391
+ const engine = resolveVirtualScrollLabels(resolvedLocale, pickEngineOverrides(overrides))
392
+ const resolved: SpreadsheetLabels = { ...engine, ...OWN_LABEL_CATALOGS[resolvedLocale] }
393
+ // 明示 undefined は「未指定」= カタログ値のまま (分割代入既定と同じ)
394
+ for (const key of OWN_LABEL_KEYS) {
395
+ // Object.prototype 汚染で継承されたラベルキーを上書きとして拾わない
396
+ const value = Object.hasOwn(overrides, key) ? overrides[key] : undefined
209
397
  if (value !== undefined) {
210
398
  putLabel(resolved, key, value)
211
399
  }
@@ -214,12 +402,36 @@ export const resolveLabels = (overrides: SpreadsheetLabelOverrides | undefined):
214
402
  return Object.freeze(resolved)
215
403
  }
216
404
 
405
+ /**
406
+ * Extracts the 7 engine chrome keys of a resolved label bag as a new frozen VirtualScrollLabels
407
+ * object — the `labels` handed to the embedded VirtualGrid.
408
+ * 解決済みラベル集合からエンジンクローム 7 キーを新しい凍結 VirtualScrollLabels object として抽出 —
409
+ * 埋込み VirtualGrid へ渡す `labels`。
410
+ *
411
+ * @param labels - The resolved label bag / 解決済みラベル集合
412
+ * @returns A new frozen object holding exactly the 7 engine keys / エンジン 7 キーだけを持つ新しい凍結 object
413
+ */
414
+ export const pickEngineLabels = (labels: SpreadsheetLabels): VirtualScrollLabels => {
415
+ const engine: { -readonly [K in keyof VirtualScrollLabels]?: VirtualScrollLabels[K] } = {}
416
+ for (const key of VIRTUAL_SCROLL_LABEL_KEYS) {
417
+ engine[key] = labels[key]
418
+ }
419
+ // ループは VIRTUAL_SCROLL_LABEL_KEYS 全件を書くため (網羅はエンジン側の型証明が保証) 全キー存在が確定
420
+ return Object.freeze(engine as VirtualScrollLabels)
421
+ }
422
+
217
423
  /**
218
424
  * The single read point: resolves one label to its display string. A function-valued label is
219
425
  * called with `n` (a missing `n` is a caller bug ⇒ RangeError — no silent 0); a string label is
220
426
  * returned as-is and ignores `n`.
221
427
  * 唯一の読み出し点: ラベル 1 件を表示文字列へ解決。関数値は n で呼ぶ (n 省略は呼び手バグとして
222
428
  * RangeError — 黙って 0 にしない)、文字列値はそのまま (n は無視)。
429
+ *
430
+ * @param labels - The resolved label bag / 解決済みラベル集合
431
+ * @param key - The label key to read / 読み出すラベルキー
432
+ * @param n - The count for a count-aware (function-valued) label / 件数キー (関数値) に渡す件数
433
+ * @returns The display string / 表示文字列
434
+ * @throws {RangeError} When the label is a function and `n` is omitted / 関数値で n を省略した場合
223
435
  */
224
436
  export const resolveLabel = (labels: SpreadsheetLabels, key: keyof SpreadsheetLabels, n?: number): string => {
225
437
  const value = labels[key]