@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.
@@ -28,6 +28,10 @@
28
28
  * helper (ADR-31 — no package CSS; capability absent ⇒ O(1) false ⇒ keys absent ⇒ the DOM
29
29
  * stays byte-identical).
30
30
  *
31
+ * UI chrome locale: the resolved `locale` and the value-stable engine subset `scrollLabels` are
32
+ * forwarded to VirtualGrid, which localizes the embedded vertical bar, the horizontal bar and the
33
+ * degenerate-band "No items" — no lang attribute, no new data-* hook.
34
+ *
31
35
  * グリッド本体。VirtualGrid 結線 (両軸) とセルレンダラーを所有する。v0.4 から横軸は VirtualGrid
32
36
  * の所有 (量子化列アンカー + 残差 transform + 横バー) — v0.3 の列スタック (useColumnWindow /
33
37
  * --aqsp-hx CSS 変数 / 軸原点絶対 left / 自前 HScrollBar) は設計計画 §7-1 に従い削除。
@@ -50,17 +54,21 @@
50
54
  * v0.10 読み取り専用セル (計画 §6.5、S1): `getCellProps` が塗られた読み取り専用セルへ
51
55
  * `aria-readonly="true"` + `data-aqsp-readonly=""` を `isReadOnlyCell` ヘルパ経由で条件スタンプ
52
56
  * する (ADR-31 — パッケージ CSS なし。能力未宣言 = O(1) false = キー不在 = DOM バイト同一)。
57
+ *
58
+ * UI クロームロケール: 解決済み `locale` と値安定なエンジン部分集合 `scrollLabels` を VirtualGrid へ
59
+ * 素通しし、埋込み縦バー・横バー・退化帯の "No items" を現地化する (lang 属性・新規 data-* フックなし)。
53
60
  */
54
61
 
55
- import { type ScrollBarTapCircleOptions, VirtualGrid, type VirtualGridHandle, type VirtualGridRange } from "@aiquants/virtualscroll"
62
+ import { type ScrollBarTapCircleOptions, VirtualGrid, type VirtualGridHandle, type VirtualGridRange, type VirtualScrollLabels } from "@aiquants/virtualscroll"
56
63
  import { type HTMLAttributes, memo, type ProfilerOnRenderCallback, type ReactNode, type RefObject, useCallback, useMemo, useRef, useSyncExternalStore } from "react"
57
64
  import { type AxisModel, nextVisibleIndex } from "../model/axisModel.ts"
58
65
  import { normalizeAdapterHyperlink } from "../model/cellAnnotations.ts"
59
66
  import { alignToJustify, type CellAlign, type CellValueFormat, formatCellValue } from "../model/cellFormat.ts"
60
67
  import { type CellPaint, resolveCellPaint } from "../model/cellPaint.ts"
61
68
  import { type CfIndex, type CfMemo, createCfMemo, resolvePaintedFormat } from "../model/conditionalFormat.ts"
62
- import { isReadOnlyCell, lazyCellText, resolveCellAlign, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
69
+ import { isReadOnlyCell, lazyCellText, resolveCellAlign, resolveCellAttributes, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
63
70
  import { isVisiblyInvalid } from "../model/dataValidation.ts"
71
+ import type { SpreadsheetLocale } from "../model/labels.ts"
64
72
  import { SCROLLBAR_WIDTH, type ViewStore } from "../model/viewStore.ts"
65
73
  import { CellView, type RenderCellContext } from "./CellView.tsx"
66
74
 
@@ -291,6 +299,8 @@ type GridBodyProps = {
291
299
  testId?: string
292
300
  /** Test-only CellView re-render counter seam (§8.2). / テスト専用の再レンダー計数シーム。 */
293
301
  __onCellRender?: ProfilerOnRenderCallback
302
+ /** Test-only rendered-cell cap (engine `__maxRenderedCells` seam pass-through); undefined in production. / テスト専用の描画セル上限 (エンジン `__maxRenderedCells` シームの素通し)。production では undefined。 */
303
+ __maxRenderedCells?: number
294
304
  /**
295
305
  * v0.8 capability (with `cfMemoRef`): the root-built conditional-format index, rebuilt per
296
306
  * (rules identity, renderVersion) and read fresh at every slice. Absent ⇒ the painted format
@@ -334,6 +344,25 @@ type GridBodyProps = {
334
344
  * エンジンが translate するのは行ラッパーのみ)。
335
345
  */
336
346
  background?: ReactNode
347
+ /** Rendering-truncation signal (engine `onRenderTruncated` pass-through — trailing columns dropped under the cell cap). / 描画打切り通知 (エンジン `onRenderTruncated` の素通し — セル上限下の末尾列脱落)。 */
348
+ onRenderTruncated?: (info: { renderedCells: number; droppedCols: number }) => void
349
+ /**
350
+ * The RESOLVED UI chrome locale (never undefined — the root resolves the `locale` prop through
351
+ * `resolveVirtualScrollLocale`), forwarded to VirtualGrid's `locale`.
352
+ * 解決済みの UI クロームロケール (undefined にならない — root が `locale` prop を
353
+ * `resolveVirtualScrollLocale` で解決)。VirtualGrid の `locale` へ素通し。
354
+ */
355
+ locale: SpreadsheetLocale
356
+ /**
357
+ * The resolved engine chrome strings (the 7 VirtualScrollLabels keys: scroll arrows,
358
+ * scroll-to-edge pills, "No items"), forwarded to VirtualGrid's `labels`. MUST be value-stable:
359
+ * its identity may change only when one of the 7 strings changes, otherwise every shell render
360
+ * pierces the memo boundary.
361
+ * 解決済みエンジンクローム文言 (VirtualScrollLabels の 7 キー: スクロール矢印・端スクロールピル・
362
+ * "No items")。VirtualGrid の `labels` へ素通し。値安定であること (7 文字列のどれかが変わった時だけ
363
+ * identity が変わる — さもなくば shell 再レンダー毎に memo 境界を貫通)。
364
+ */
365
+ scrollLabels: VirtualScrollLabels
337
366
  }
338
367
 
339
368
  /**
@@ -360,6 +389,7 @@ const GridBodyInner = ({
360
389
  requestCommit,
361
390
  testId,
362
391
  __onCellRender,
392
+ __maxRenderedCells,
363
393
  cfIndexRef,
364
394
  cfMemoRef,
365
395
  isPointing,
@@ -367,6 +397,9 @@ const GridBodyInner = ({
367
397
  overscanRows,
368
398
  overscanCols,
369
399
  background,
400
+ onRenderTruncated,
401
+ locale,
402
+ scrollLabels,
370
403
  }: GridBodyProps): ReactNode => {
371
404
  const internalBodyRef = useRef<HTMLDivElement | null>(null)
372
405
  // 外部 ref (エンジン結線用) と内部 ref の合成
@@ -427,6 +460,11 @@ const GridBodyInner = ({
427
460
  // 宣言ホストは塗りセル毎 1 述語読み (ADR-31 の登記コスト)
428
461
  ...(isReadOnlyCell(adapter, row, col) ? { "aria-readonly": "true", "data-aqsp-readonly": "" } : {}),
429
462
  }
463
+ // v0.12 ホスト属性の継ぎ目: resolveCellAttributes が予約キー (data-aqsp* / data-row /
464
+ // data-col) と非 data-* を fail-fast 済みなのでパッケージ属性と衝突しない。未宣言は
465
+ // O(1) null = v0.11 のセル DOM とバイト同一
466
+ const hostAttrs = resolveCellAttributes(adapter, row, col)
467
+ const stamped = hostAttrs === null ? base : { ...base, ...hostAttrs }
430
468
  const rich = resolveRichCellProps(adapter, rowAxis, colAxis, cf, row, col)
431
469
  // 掛け金は毎セル更新 (v0.1 恒等の null でも座標を進める — children が消費するのは書式能力下だけ)
432
470
  const latch = displayLatchRef.current
@@ -434,9 +472,9 @@ const GridBodyInner = ({
434
472
  latch.col = col
435
473
  latch.value = rich === null ? undefined : rich.value
436
474
  if (rich === null) {
437
- return base as HTMLAttributes<HTMLDivElement>
475
+ return stamped as HTMLAttributes<HTMLDivElement>
438
476
  }
439
- return (rich.style === null ? { ...base, ...rich.attrs } : { ...base, ...rich.attrs, style: rich.style }) as HTMLAttributes<HTMLDivElement>
477
+ return (rich.style === null ? { ...stamped, ...rich.attrs } : { ...stamped, ...rich.attrs, style: rich.style }) as HTMLAttributes<HTMLDivElement>
440
478
  },
441
479
  [adapter, rowAxis, colAxis, cf],
442
480
  )
@@ -463,15 +501,17 @@ const GridBodyInner = ({
463
501
  (text: string, row: number, col: number): ReactNode => {
464
502
  // renderVersion は識別子として参照する (identity-bust — 値そのものは使わない)
465
503
  void renderVersion
466
- // v0.7 表示形式: renderCell 指定時は素の text を素通し (escape hatch・組み込み値書式に優先)、
467
- // 未指定時のみ値書式を表示文字列へ適用 (編集 / コピーは素の値のまま)。値書式は直前の
468
- // getCellProps が掛け金へ置いた塗り書式の value (= base の value — CF 語彙に value は無い) で、
469
- // アダプタは再読しない (書式能力なしは掛け金にも触れない v0.7 恒等)
504
+ // v0.7 表示形式 / v0.12 オプトアウト: 値書式は renderCell の有無に依らず書式能力下で
505
+ // 常に解決する — カスタムセルは ctx.displayText として受け取り (読み上げ / クリップ
506
+ // ボード / autofit と同一文字列)、undefined 返しのオプトアウトセルは既定描画がこれを
507
+ // 描く。値は直前の getCellProps が掛け金へ置いた塗り書式の value (= base の value —
508
+ // CF 語彙に value は無い) で、アダプタは再読しない (書式能力なしは掛け金にも触れない
509
+ // v0.7 恒等)
470
510
  let display = text
471
- if (renderCell === undefined && adapter.getCellFormat !== undefined) {
511
+ if (adapter.getCellFormat !== undefined) {
472
512
  display = formatCellValue(text, takeLatchedValue(displayLatchRef.current, row, col))
473
513
  }
474
- return <CellView row={row} col={col} text={display} renderCell={renderCell} __onCellRender={__onCellRender} />
514
+ return <CellView row={row} col={col} text={text} display={display} renderCell={renderCell} __onCellRender={__onCellRender} />
475
515
  },
476
516
  [renderVersion, renderCell, __onCellRender, adapter],
477
517
  )
@@ -539,6 +579,10 @@ const GridBodyInner = ({
539
579
  overscanRows={overscanRows}
540
580
  overscanCols={overscanCols}
541
581
  background={background}
582
+ onRenderTruncated={onRenderTruncated}
583
+ __maxRenderedCells={__maxRenderedCells}
584
+ locale={locale}
585
+ labels={scrollLabels}
542
586
  callbackThrottleMs={0}
543
587
  onScroll={onScroll}
544
588
  onRangeChange={onRangeChange}
package/src/index.ts CHANGED
@@ -36,7 +36,7 @@
36
36
  * (分冊 00 §5.2)。
37
37
  */
38
38
 
39
- // 統合タップサークル (エンジン v3.6.0 — ADR-33 縮小形): tapScrollCircleOptions prop (v0.10 H2) と
39
+ // 統合タップサークル (エンジン v3.6.0 — エンジン ADR-33 縮小形): tapScrollCircleOptions prop (v0.10 H2) と
40
40
  // renderVisual をエンジン直接依存なしで型付ける型のみ再 export。値面 (tapScrollCircleSampleVisual) は
41
41
  // 意図的に非再 export — エンジン所有のまま
42
42
  export type { ScrollBarTapCircleOptions, TapScrollCircleRenderProps } from "@aiquants/virtualscroll"
@@ -110,7 +110,7 @@ export {
110
110
  validateCellText,
111
111
  } from "./model/dataValidation.ts"
112
112
  export { fromR1C1, shiftFormula, toR1C1 } from "./model/formulaRefs.ts"
113
- export { DEFAULT_LABELS, resolveLabel, type SpreadsheetLabelOverrides, type SpreadsheetLabels } from "./model/labels.ts"
113
+ export { resolveLabel, resolveLabels, SPREADSHEET_LABEL_CATALOGS, type SpreadsheetLabelOverrides, type SpreadsheetLabels, type SpreadsheetLocale } from "./model/labels.ts"
114
114
  export { DEFAULT_HIGHLIGHT_COLORS, type HighlightColors, type HighlightState } from "./overlay/SelectionOverlay.tsx"
115
115
  // 既定ポップオーバー 6 種 — renderPopover の部品 (kind 別に差し替え、残りは既定を組み合わせる)
116
116
  export { CommentPopover, type CommentPopoverProps } from "./popover/CommentPopover.tsx"
@@ -66,7 +66,7 @@ import { type RefObject, useEffect, useRef } from "react"
66
66
  import type { PasteOptions } from "../clipboard/pasteSpecial.ts"
67
67
  import type { PointingSeams } from "../editor/useCellEditor.ts"
68
68
  import { type AxisModel, nextVisibleIndex } from "../model/axisModel.ts"
69
- import { type CellCoord, type CellRange, countRangeCells, enumerateRangeCells, makeRange, type SpreadsheetDirection, type SpreadsheetOperation } from "../model/cellCoord.ts"
69
+ import { type CellCoord, type CellRange, clampCoord, countRangeCells, enumerateRangeCells, makeRange, type SpreadsheetDirection, type SpreadsheetOperation } from "../model/cellCoord.ts"
70
70
  import { aspectCapabilities, type CellEdit, type EditRejection, type ReadOnlyRejection, rangesContainReadOnly, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
71
71
  import { colToViewportX, rowToViewportY } from "../model/frozenPartition.ts"
72
72
  import { reduceActiveCycle, type SelectionState } from "../selection/selectionState.ts"
@@ -450,16 +450,21 @@ export const useGridKeyboard = (params: UseGridKeyboardParams): void => {
450
450
  const reference = pointing.getReferenceCursor()
451
451
  if (reference !== null) {
452
452
  event.preventDefault()
453
+ // トークン由来の座標は生存セッションが行数 / 列数の縮小 (ADR-33) を跨いだあと範囲外
454
+ // になり得る (手打ち "=A999" も同じ) — moveActive の入力 clamp と同じ規律で消費点に
455
+ // 畳んでから歩く (軸の範囲外読みは Fenwick が fail-fast する)
456
+ const cursor = clampCoord(reference.cursor, p.rowAxis.count, p.colAxis.count)
457
+ const origin = clampCoord(reference.origin, p.rowAxis.count, p.colAxis.count)
453
458
  // Ctrl はデータ端 (idle の Ctrl+Arrow と同じ導出)、素は可視 1 歩。active は動かさない
454
- const next = ctrl ? dataEdgeTarget(reference.cursor, direction) : arrowStep(reference.cursor, direction)
459
+ const next = ctrl ? dataEdgeTarget(cursor, direction) : arrowStep(cursor, direction)
455
460
  p.ensureCellVisible(next)
456
461
  // Shift はトークン起点とのレンジへ置換、素はカーソル単独
457
- pointing.insertReference(event.shiftKey ? makeRange(reference.origin, next) : next, reference.axis)
462
+ pointing.insertReference(event.shiftKey ? makeRange(origin, next) : next, reference.axis)
458
463
  // 指し先の参照矩形 (§2.7): 挿入したトークンと同じ範囲を "point" 矩形で描く
459
464
  // (Excel / Sheets のキーボード指し枠)。帯参照 (A:C / 2:5) は軸全域へ展開 —
460
465
  // トークンの意味する範囲そのもの。能力分岐: writer 未配線 = 矩形なし (参照書換のみ)
461
466
  if (p.onPointFrame !== undefined) {
462
- const rect = event.shiftKey ? makeRange(reference.origin, next) : makeRange(next, next)
467
+ const rect = event.shiftKey ? makeRange(origin, next) : makeRange(next, next)
463
468
  p.onPointFrame(
464
469
  reference.axis === "columns"
465
470
  ? makeRange({ row: 0, col: rect.startCol }, { row: p.rowAxis.count - 1, col: rect.endCol })
@@ -266,10 +266,12 @@ export type SpreadsheetDataAdapter = {
266
266
  * string only — editing keeps the raw getCellText (Excel semantics) and copy/TSV carry the raw
267
267
  * value too (a DELIBERATE deviation from Excel, which copies the display — raw preserves the
268
268
  * numeric round-trip). Autofit measures the FORMATTED display, and screen-reader
269
- * announcements receive both the raw text and the display string. `renderCell` (a prop)
270
- * takes precedence over the built-in VALUE format (raw text flows to the custom renderer),
271
- * but the resolved ALIGNMENT and paint are cell properties that still apply under
272
- * renderCell. Every format is fail-soft: a non-numeric value in a numeric format, an
269
+ * announcements receive both the raw text and the display string. A `renderCell` (prop)
270
+ * cell that returns custom output shows that output instead of the built-in VALUE format
271
+ * (raw `text` and the formatted `displayText` both flow to the custom renderer); a cell whose
272
+ * renderCell returns `undefined` falls back to the formatted default (v0.12 per-cell opt-out),
273
+ * and the resolved ALIGNMENT and paint are cell properties that apply under renderCell
274
+ * regardless. Every format is fail-soft: a non-numeric value in a numeric format, an
273
275
  * invalid date, an out-of-range Intl option or an unparseable colour renders / paints
274
276
  * nothing for that key. Return a shared/interned CellFormat per column (or per cell object)
275
277
  * to avoid per-cell allocation and to keep the paint intern stable; because return values
@@ -283,9 +285,10 @@ export type SpreadsheetDataAdapter = {
283
285
  * **塗り書式** (この base ⊕ 条件付き書式)。値書式は表示文字列にのみ適用し、編集は素の
284
286
  * getCellText (Excel 意味論)、コピー / TSV も素の値を運ぶ (Excel は表示を写すため**意図的な
285
287
  * 逸脱** — 素値は数値の往復を保つ)。autofit は**整形後の表示**を計測し、読み上げには素の
286
- * text と表示文字列の両方を渡す。`renderCell` (prop) は組み込み「値書式」に優先する (素の
287
- * text がカスタムレンダラーへ) が、解決済み「揃え」と塗りはセル固有属性として renderCell 下
288
- * でも効く。全書式は fail-soft — 数値書式の非数値・無効な日付・範囲外 Intl オプション・解釈
288
+ * text と表示文字列の両方を渡す。`renderCell` (prop) がカスタム出力を返したセルは組み込み
289
+ * 「値書式」の代わりにその出力を描く (素の text と整形後 displayText の両方がカスタム
290
+ * レンダラーへ)。undefined を返したセルは整形済みの既定描画へ降りる (v0.12 のセル単位
291
+ * オプトアウト)。解決済み「揃え」と塗りはセル固有属性として renderCell 下でも常に効く。全書式は fail-soft — 数値書式の非数値・無効な日付・範囲外 Intl オプション・解釈
289
292
  * 不能な色はそのキーだけ何も描かない。列毎 (またはセル object 毎) に共有 CellFormat を返すと
290
293
  * セル毎の確保を避け塗りの intern も安定する。戻り値は (getCellText 同様) 再スライス時のみ
291
294
  * 再読込されるため、書式変更の反映は dataVersion 増分 (またはアダプタ差替) で行う。
@@ -398,6 +401,31 @@ export type SpreadsheetDataAdapter = {
398
401
  * (getCellText と同格、描画パスでも呼ばれるため)。未宣言 = 全セル編集可 = 従来挙動とバイト同一。
399
402
  */
400
403
  isCellReadOnly?: (row: number, col: number) => boolean
404
+ /**
405
+ * OPTIONAL capability (v0.12): host-owned `data-*` attributes for one cell, merged verbatim
406
+ * onto the PACKAGE-OWNED cell element (`[data-aqsp-cell]`) so host CSS can target cell
407
+ * state, `:hover` and theme tokens without wrapper elements inside the cell. The package
408
+ * never interprets the values. Validated fail-fast at render (RangeError): the return must
409
+ * be a plain record or `null` (a JS host's `undefined` / array / Map is rejected by name,
410
+ * never a bare TypeError), every key must be a lowercase `data-*` name
411
+ * (`data-[a-z0-9-]+`), the package- and engine-emitted cell attributes (`data-aqsp*`,
412
+ * `data-aqvs*`, `data-row`, `data-col`) are reserved, and every value must be a string
413
+ * (React would otherwise stringify objects or drop `undefined` silently). Read once per
414
+ * painted cell per slice (the `getCellText` cost budget — keep it O(1)); publish changes
415
+ * via `dataVersion`. `null` = no extra attributes for this cell. Absence = the previous
416
+ * version's cell DOM byte-identically.
417
+ * 任意能力 (v0.12): セル 1 つへのホスト所有 `data-*` 属性 — パッケージ所有のセル要素
418
+ * (`[data-aqsp-cell]`) へそのまま載せる継ぎ目。セル内に器 (ラッパー要素) を敷かずにホスト
419
+ * CSS がセル状態・`:hover`・テーマトークンを掴むための口で、値の解釈はパッケージが
420
+ * 持たない。render 時 fail-fast 検証 (RangeError): 戻りはプレーンな record か `null`
421
+ * (JS ホストの `undefined` / 配列 / Map は名指しで拒否 — 生 TypeError にしない)、キーは
422
+ * 小文字 `data-*` (`data-[a-z0-9-]+`) のみ、パッケージ / エンジン放出のセル属性
423
+ * (`data-aqsp*` / `data-aqvs*` / `data-row` / `data-col`) は予約、値は string のみ (React の
424
+ * 暗黙文字列化や `undefined` の無言脱落を許さない)。描画セル毎・スライス毎に 1 読み
425
+ * (getCellText と同格の予算 — O(1) に保つ)。変更は dataVersion で公開。null = このセルに
426
+ * 追加属性なし。未宣言 = 従来のセル DOM とバイト同一。
427
+ */
428
+ getCellAttributes?: (row: number, col: number) => Readonly<Record<string, string>> | null
401
429
  /**
402
430
  * OPTIONAL capability (v0.8): the numeric minimum / maximum over a range, for colorScale
403
431
  * rules whose end points omit `value`; null = the range holds no numeric value. Memoised by
@@ -553,6 +581,69 @@ export const readOnlyDeclared = (adapter: SpreadsheetDataAdapter): boolean => ad
553
581
  */
554
582
  export const isReadOnlyCell = (adapter: SpreadsheetDataAdapter, row: number, col: number): boolean => adapter.isCellReadOnly?.(row, col) === true
555
583
 
584
+ /** Host cell-attribute key grammar (v0.12): lowercase `data-*` only. / ホストセル属性キーの文法 (v0.12): 小文字 `data-*` のみ。 */
585
+ const CELL_ATTRIBUTE_KEY = /^data-[a-z0-9-]+$/
586
+
587
+ /**
588
+ * Whether a value is a plain record (object literal or null-prototype object — not an array,
589
+ * Map, class instance or primitive): the only shape `getCellAttributes` may return besides null.
590
+ * プレーンな record (object リテラルか null プロトタイプ object — 配列 / Map / class インスタンス /
591
+ * プリミティブではない) か: `getCellAttributes` が null 以外で返してよい唯一の形。
592
+ */
593
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> => {
594
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
595
+ return false
596
+ }
597
+ const proto = Object.getPrototypeOf(value)
598
+ return proto === Object.prototype || proto === null
599
+ }
600
+
601
+ /**
602
+ * Resolves the host cell-attribute capability for one cell: `null` in O(1) without calling
603
+ * anything when the capability is absent (the byte-identity lever), otherwise the host's record
604
+ * verbatim after fail-fast validation — the return must be a plain record (a JS host's
605
+ * `undefined` / array / Map / primitive is rejected by name, never a bare TypeError), every key
606
+ * must be a lowercase `data-*` name that does not shadow a package- or engine-emitted cell
607
+ * attribute (`data-aqsp*` / `data-aqvs*` / `data-row` / `data-col` — the engine spreads its own
608
+ * `data-aqvs-*` band stamps AFTER the host props, so a colliding host key would win in the
609
+ * scroll band and lose in the frozen bands), and every value must be a string. Throws a
610
+ * RangeError naming the offending shape / key. A throw from the host callback is a host bug and
611
+ * propagates (no try/catch — Strict No-Fallback).
612
+ * セル 1 つのホスト属性能力の解決: 能力未宣言なら何も呼ばず O(1) で null (バイト恒等の支点)。
613
+ * 宣言時は fail-fast 検証 — 戻りはプレーンな record (JS ホストの `undefined` / 配列 / Map /
614
+ * プリミティブは名指しで拒否、生 TypeError にしない)、キーは小文字 `data-*` かつパッケージ /
615
+ * エンジン放出のセル属性 (`data-aqsp*` / `data-aqvs*` / `data-row` / `data-col`) を影書きしない
616
+ * (エンジンは自身の `data-aqvs-*` 帯スタンプをホスト props の**後**に spread するため、衝突キーは
617
+ * スクロール帯では勝ち凍結帯では負ける)、値は string のみ — の後、ホストの record をそのまま
618
+ * 返す。違反は形 / キーを名指しの RangeError。ホスト callback の throw はホストのバグとして
619
+ * 素通し (try/catch しない)。
620
+ */
621
+ export const resolveCellAttributes = (adapter: SpreadsheetDataAdapter, row: number, col: number): Readonly<Record<string, string>> | null => {
622
+ if (adapter.getCellAttributes === undefined) {
623
+ return null
624
+ }
625
+ const attrs = adapter.getCellAttributes(row, col)
626
+ if (attrs === null) {
627
+ return null
628
+ }
629
+ // 型外の戻り (JS ホスト) は Object.keys の生 TypeError や Map の無言「空属性」でなく名指しで拒否する
630
+ if (!isPlainRecord(attrs)) {
631
+ throw new RangeError(`[spreadsheet] getCellAttributes must return a plain data-* record or null (got ${Object.prototype.toString.call(attrs)})`)
632
+ }
633
+ for (const [key, value] of Object.entries(attrs)) {
634
+ if (!CELL_ATTRIBUTE_KEY.test(key)) {
635
+ throw new RangeError(`[spreadsheet] getCellAttributes key must be a lowercase data-* name (got ${JSON.stringify(key)})`)
636
+ }
637
+ if (key.startsWith("data-aqsp") || key.startsWith("data-aqvs") || key === "data-row" || key === "data-col") {
638
+ throw new RangeError(`[spreadsheet] getCellAttributes key ${JSON.stringify(key)} shadows a package- or engine-emitted cell attribute`)
639
+ }
640
+ if (typeof value !== "string") {
641
+ throw new RangeError(`[spreadsheet] getCellAttributes value for ${JSON.stringify(key)} must be a string (got ${typeof value})`)
642
+ }
643
+ }
644
+ return attrs
645
+ }
646
+
556
647
  /**
557
648
  * Whether a grid-clamped rect contains at least one read-only cell: rectangular walk with
558
649
  * short-circuit on first hit; `false` in O(1) when the capability is absent. For the
@@ -26,7 +26,8 @@
26
26
  * - INTERNED list Sets per rule object (WeakMap identity), so the paint-time `list` check is O(1)
27
27
  * even for a 10k-item list; a fresh rule object interns a fresh Set.
28
28
  * - MESSAGE resolution: `errorMessage` when the consumer set it (an empty string is honoured as
29
- * is), else a built-in English summary of the rule.
29
+ * is), else the built-in summary of the rule in the caller's REQUIRED `locale`
30
+ * (validationMessages.ts); an unsupported locale throws RangeError before any verdict.
30
31
  * - `custom` needs the caller's `evaluate`; without it the rule is not evaluated (verdict ok), and
31
32
  * truth = `true` or a finite non-zero number (the conditionalFormat customFormula convention).
32
33
  * - Matching is exact (case-sensitive, untrimmed) for `list` / `text` / `checkbox`; textLength
@@ -43,11 +44,15 @@
43
44
  * タプル比較 (between は両端含み)、不完全な規則 (解釈不能な境界・between の b 欠落・needle 欠落) は
44
45
  * 不活性 = 全受理 (書きかけの規則でセルを閉じ込めない、境界の入替もしない)、語彙外の type / op は
45
46
  * consumer バグとして text に依らず RangeError、list の Set は規則 object 毎に WeakMap intern、
46
- * メッセージは errorMessage (空文字も尊重) が無ければ組込みの英語要約、custom は evaluate 無しなら
47
+ * メッセージは errorMessage (空文字も尊重) が無ければ呼び手必須の locale による組込み要約
48
+ * (validationMessages.ts — 非対応ロケールは判定前に RangeError)、custom は evaluate 無しなら
47
49
  * 未評価 (ok)・真 = true か有限非 0 数、照合は完全一致 (大文字小文字区別・trim 無し)、textLength は
48
50
  * コードポイント数、isEmail は WHATWG 文法 + ドット付きドメイン必須、isUrl は WHATWG 絶対 http(s) URL。
49
51
  */
50
52
 
53
+ import { describeLabelValue, type SpreadsheetLocale } from "./labels.ts"
54
+ import { VALIDATION_MESSAGE_CATALOGS, type ValidationMessageCatalog } from "./validationMessages.ts"
55
+
51
56
  /** Ordered comparison vocabulary shared by number / textLength / date rules; between / notBetween include both bounds. / number / textLength / date 規則が共有する比較語彙。between / notBetween は両端含み。 */
52
57
  export type ValidationCompareOp = "gt" | "gte" | "lt" | "lte" | "eq" | "neq" | "between" | "notBetween"
53
58
 
@@ -127,15 +132,6 @@ type Ordering = -1 | 0 | 1
127
132
  /** Resolved bounds of an ordered rule; `b` is null when the op does not use it. / 順序規則の解決済み境界。op が使わないとき b は null。 */
128
133
  type Bounds<T> = { a: T; b: T | null }
129
134
 
130
- /** Rule-family wording for the four ordered ops (the other four are shared). / 順序 4 op の規則族別の言い回し (残る 4 つは共通)。 */
131
- type OrderWords = { readonly gt: string; readonly gte: string; readonly lt: string; readonly lte: string }
132
-
133
- /** Wording for number / textLength messages. / number / textLength メッセージの言い回し。 */
134
- const NUMERIC_WORDS: OrderWords = { gt: "greater than", gte: "greater than or equal to", lt: "less than", lte: "less than or equal to" }
135
-
136
- /** Wording for date messages. / date メッセージの言い回し。 */
137
- const DATE_WORDS: OrderWords = { gt: "after", gte: "on or after", lt: "before", lte: "on or before" }
138
-
139
135
  /**
140
136
  * Parses a strict decimal string into its exact normalised form: trims, applies the
141
137
  * cellFormat.ts grammar (hex / grouping / empty rejected, exponent overflow to ±Infinity
@@ -367,9 +363,16 @@ const checkboxStrings = (rule: CheckboxValidationRule): { checked: string; unche
367
363
 
368
364
  /**
369
365
  * Rejects a rule outside the vocabulary (unknown `type`, or an unknown `op` on number /
370
- * textLength / date / text) with RangeError — a consumer bug surfaces regardless of the text.
366
+ * textLength / date / text) with RangeError — a consumer bug surfaces regardless of the text. The
367
+ * rejected value is described by `describeLabelValue`, so a Symbol / null-prototype `type` or `op`
368
+ * still yields the RangeError (never a TypeError from the message itself).
371
369
  * 語彙外の規則 (未知 type、number / textLength / date / text の未知 op) を RangeError で拒否 —
372
- * consumer バグは text に依らず表面化。
370
+ * consumer バグは text に依らず表面化。拒否値は `describeLabelValue` で説明するため、Symbol / null
371
+ * プロトタイプの type や op でもメッセージ組立て自体が TypeError を投げず RangeError のまま。
372
+ *
373
+ * @param rule - The rule to check / 検査する規則
374
+ * @returns Nothing; returns normally for an in-vocabulary rule / なし (語彙内の規則なら正常終了)
375
+ * @throws {RangeError} When the rule's `type` / `op` is outside the vocabulary / 規則の type / op が語彙外の場合
373
376
  */
374
377
  const assertRuleShape = (rule: DataValidationRule): void => {
375
378
  switch (rule.type) {
@@ -381,16 +384,16 @@ const assertRuleShape = (rule: DataValidationRule): void => {
381
384
  case "textLength":
382
385
  case "date":
383
386
  if (!COMPARE_OPS.has(rule.op)) {
384
- throw new RangeError(`[spreadsheet] unknown data-validation op "${String(rule.op)}" for type "${rule.type}"`)
387
+ throw new RangeError(`[spreadsheet] unknown data-validation op ${describeLabelValue(rule.op)} for type "${rule.type}"`)
385
388
  }
386
389
  return
387
390
  case "text":
388
391
  if (!TEXT_OPS.has(rule.op)) {
389
- throw new RangeError(`[spreadsheet] unknown data-validation op "${String(rule.op)}" for type "text"`)
392
+ throw new RangeError(`[spreadsheet] unknown data-validation op ${describeLabelValue(rule.op)} for type "text"`)
390
393
  }
391
394
  return
392
395
  default:
393
- throw new RangeError(`[spreadsheet] unknown data-validation rule type "${String((rule as { type: unknown }).type)}"`)
396
+ throw new RangeError(`[spreadsheet] unknown data-validation rule type ${describeLabelValue((rule as { type: unknown }).type)}`)
394
397
  }
395
398
  }
396
399
 
@@ -427,68 +430,61 @@ const accepts = (rule: DataValidationRule, text: string, evaluate: ValidationEva
427
430
  return text === "" || ruleHolds(rule, text, evaluate)
428
431
  }
429
432
 
430
- /** Phrase for an ordered op with the family wording: "greater than 5", "between 1 and 9", ... / 順序 op の言い回し (規則族の語彙付き)。 */
431
- const comparePhrase = (op: ValidationCompareOp, a: string, b: string | undefined, words: OrderWords): string => {
432
- switch (op) {
433
- case "gt":
434
- case "gte":
435
- case "lt":
436
- case "lte":
437
- return `${words[op]} ${a}`
438
- case "eq":
439
- return `equal to ${a}`
440
- case "neq":
441
- return `not equal to ${a}`
442
- case "between":
443
- return `between ${a} and ${b}`
444
- default:
445
- // 残る順序 op は notBetween
446
- return `not between ${a} and ${b}`
447
- }
448
- }
449
-
450
433
  /** Trims an optional bound string for display. / 表示用に任意の境界文字列を trim。 */
451
434
  const trimBound = (bound: string | undefined): string | undefined => (bound === undefined ? undefined : bound.trim())
452
435
 
453
- /** Built-in English summary of a rule, used when the consumer set no `errorMessage`. / consumer が errorMessage を置かないときの組込み英語要約。 */
454
- const defaultMessage = (rule: DataValidationRule): string => {
436
+ /**
437
+ * Built-in summary of a rule in one locale's catalog, used when the consumer set no
438
+ * `errorMessage`. The bounds are normalised here (number / date bounds trimmed, textLength bounds
439
+ * stringified, checkbox strings defaulted) so every locale's template sees the same values.
440
+ * consumer が errorMessage を置かないときの、1 ロケールのカタログによる規則の組込み要約。境界の正規化
441
+ * (number / date は trim、textLength は文字列化、checkbox は既定適用) をここで行い、全ロケールの
442
+ * テンプレートが同じ値を受け取る。
443
+ *
444
+ * @param rule - The shape-checked rule that rejected the text / text を拒否した形検査済みの規則
445
+ * @param catalog - The message catalog of the verdict's locale / 判定ロケールのメッセージカタログ
446
+ * @returns The built-in message / 組込みメッセージ
447
+ */
448
+ const messageOf = (rule: DataValidationRule, catalog: ValidationMessageCatalog): string => {
455
449
  switch (rule.type) {
456
450
  case "list":
457
- return "Value must be one of the list items."
451
+ return catalog.list
458
452
  case "number":
459
- return `Value must be a number ${comparePhrase(rule.op, rule.a.trim(), trimBound(rule.b), NUMERIC_WORDS)}.`
453
+ return catalog.number[rule.op](rule.a.trim(), trimBound(rule.b))
460
454
  case "textLength":
461
- return `Text length must be ${comparePhrase(rule.op, String(rule.a), rule.b === undefined ? undefined : String(rule.b), NUMERIC_WORDS)}.`
455
+ return catalog.textLength[rule.op](String(rule.a), rule.b === undefined ? undefined : String(rule.b))
462
456
  case "date":
463
- return `Value must be a date ${comparePhrase(rule.op, rule.a.trim(), trimBound(rule.b), DATE_WORDS)}.`
457
+ return catalog.date[rule.op](rule.a.trim(), trimBound(rule.b))
464
458
  case "text":
465
- return textMessage(rule)
459
+ return textMessageOf(rule, catalog.text)
466
460
  case "checkbox": {
467
461
  const strings = checkboxStrings(rule)
468
- return `Value must be "${strings.checked}" or "${strings.unchecked}".`
462
+ return catalog.checkbox(strings.checked, strings.unchecked)
469
463
  }
470
464
  default:
471
465
  // 残る規則種別は custom
472
- return `Value does not satisfy the formula ${rule.formula}.`
466
+ return catalog.custom(rule.formula)
473
467
  }
474
468
  }
475
469
 
476
- /** Built-in summary of a text rule. / text 規則の組込み要約。 */
477
- const textMessage = (rule: TextValidationRule): string => {
470
+ /**
471
+ * Built-in summary of a text rule from a catalog's `text` templates (needle ops embed `value`; the
472
+ * shape checks are fixed sentences).
473
+ * カタログの text テンプレートによる text 規則の組込み要約 (needle 系は value を埋め込み、形式検査は固定文)。
474
+ *
475
+ * @param rule - The shape-checked text rule / 形検査済みの text 規則
476
+ * @param templates - The `text` templates of the verdict's locale / 判定ロケールの text テンプレート
477
+ * @returns The built-in message / 組込みメッセージ
478
+ */
479
+ const textMessageOf = (rule: TextValidationRule, templates: ValidationMessageCatalog["text"]): string => {
478
480
  switch (rule.op) {
479
- case "contains":
480
- return `Value must contain "${rule.value}".`
481
- case "notContains":
482
- return `Value must not contain "${rule.value}".`
483
- case "startsWith":
484
- return `Value must start with "${rule.value}".`
485
- case "endsWith":
486
- return `Value must end with "${rule.value}".`
487
481
  case "isEmail":
488
- return "Value must be a valid email address."
482
+ return templates.isEmail
483
+ case "isUrl":
484
+ return templates.isUrl
489
485
  default:
490
- // 残る text op は isUrl
491
- return "Value must be a valid URL."
486
+ // needle 系は value 欠落なら不活性で拒否に至らないため、ここへ来るとき value は常にある
487
+ return templates[rule.op](rule.value as string)
492
488
  }
493
489
  }
494
490
 
@@ -513,20 +509,41 @@ export const listValuesOf = (rule: ListValidationRule): ReadonlySet<string> => {
513
509
  }
514
510
 
515
511
  /**
516
- * Validates committed text against a rule. Blank always passes; `custom` is evaluated only when
517
- * the caller passes `evaluate` (commit time — paint omits it); an incomplete rule (unparseable
518
- * bound, missing `b` / `value`) is inert; a rule outside the vocabulary throws RangeError.
519
- * Message = `rule.errorMessage` when set, else a built-in English summary of the rule.
520
- * 確定テキストの規則検証。空文字は常に合格、custom は呼び手が evaluate を渡すとき (確定時 — 描画は
521
- * 渡さない) だけ評価、不完全な規則 (解釈不能な境界・b / value 欠落) は不活性、語彙外の規則は
522
- * RangeError。メッセージは errorMessage があればそれ、無ければ組込みの英語要約。
512
+ * Validates committed text against a rule. The locale is checked FIRST (before the rule and the
513
+ * text), so an unsupported locale fails fast even for a passing text — this pure function has no
514
+ * hidden default locale. Blank always passes; `custom` is evaluated only when the caller passes
515
+ * `evaluate` (commit time — paint omits it); an incomplete rule (unparseable bound, missing `b` /
516
+ * `value`) is inert; a rule outside the vocabulary throws RangeError. Message = `rule.errorMessage`
517
+ * when set (the empty string included), else the built-in summary in the given locale.
518
+ * 確定テキストの規則検証。ロケールを最初に (規則と text より前に) 検査するため、非対応ロケールは
519
+ * 合格する text でも fail-fast (純関数に隠れた既定ロケールは無い)。空文字は常に合格、custom は呼び手が
520
+ * evaluate を渡すとき (確定時 — 描画は渡さない) だけ評価、不完全な規則 (解釈不能な境界・b / value 欠落) は
521
+ * 不活性、語彙外の規則は RangeError。メッセージは errorMessage があればそれ (空文字も含む)、無ければ
522
+ * 指定ロケールの組込み要約。
523
+ *
524
+ * @param rule - The cell's data-validation rule / セルのデータ検証規則
525
+ * @param text - The committed text / 確定テキスト
526
+ * @param locale - The UI chrome locale of the built-in message ("en" | "ja"; required) / 組込みメッセージの UI クロームロケール ("en" | "ja"、必須)
527
+ * @param evaluate - The consumer's formula evaluator for `custom` rules (omit to leave them unevaluated) / custom 規則用の consumer の数式評価器 (省略で未評価)
528
+ * @returns The frozen shared ok verdict, or a rejection carrying the resolved message / 共有の凍結 ok 判定、または解決済みメッセージ付きの拒否
529
+ * @throws {RangeError} When `locale` is not a supported SpreadsheetLocale (`undefined`, `null`,
530
+ * `"EN"`, `"ja-JP"`, ...), or the rule's `type` / `op` is outside the vocabulary / `locale` が
531
+ * 対応 SpreadsheetLocale でない場合 (`undefined`、`null`、`"EN"`、`"ja-JP"` など)、または規則の
532
+ * type / op が語彙外の場合
523
533
  */
524
- export const validateCellText = (rule: DataValidationRule, text: string, evaluate?: ValidationEvaluate): ValidationVerdict => {
534
+ export const validateCellText = (rule: DataValidationRule, text: string, locale: SpreadsheetLocale, evaluate?: ValidationEvaluate): ValidationVerdict => {
535
+ // 型を外れた呼び手 (JS・as キャスト) の undefined を "en" へ黙って寄せると、言語の取り違えが表面化しない
536
+ if (typeof locale !== "string" || !Object.hasOwn(VALIDATION_MESSAGE_CATALOGS, locale)) {
537
+ const allowed = Object.keys(VALIDATION_MESSAGE_CATALOGS)
538
+ .map((candidate) => JSON.stringify(candidate))
539
+ .join(", ")
540
+ throw new RangeError(`[spreadsheet] validateCellText locale must be one of ${allowed}; got ${describeLabelValue(locale)}`)
541
+ }
525
542
  if (accepts(rule, text, evaluate)) {
526
543
  return VALID
527
544
  }
528
545
  // errorMessage は「置かれているか」だけを見る (空文字も consumer の意思として尊重)
529
- return { ok: false, message: rule.errorMessage === undefined ? defaultMessage(rule) : rule.errorMessage }
546
+ return { ok: false, message: rule.errorMessage === undefined ? messageOf(rule, VALIDATION_MESSAGE_CATALOGS[locale]) : rule.errorMessage }
530
547
  }
531
548
 
532
549
  /** Whether a rule can be judged at paint time without a formula evaluator (every type but `custom`). / 数式評価器なしに描画時判定できる規則か (custom 以外の全型)。 */