@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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @fileoverview Built-in data-validation verdict messages per UI chrome locale — the table that
3
+ * validateCellText reads when a rule carries no `errorMessage`. INTERNAL: not exported from the
4
+ * package barrel; the public override stays the per-rule `errorMessage`. Pure, React-free.
5
+ *
6
+ * Contract (pinned by validationMessages.spec.ts and dataValidation.spec.ts):
7
+ * - One frozen ValidationMessageCatalog per SpreadsheetLocale; every template takes the bounds
8
+ * already normalised by dataValidation.ts (number / date bounds trimmed, textLength bounds
9
+ * stringified, checkbox strings defaulted) and never re-normalises them.
10
+ * - Templates are WHOLE SENTENCES per (rule type, op): Japanese puts the bound before the
11
+ * predicate, so a fragment table ("greater than" + bound) cannot express it.
12
+ * - `en` is byte-identical to the pre-i18n English summaries ("Value must be a number greater
13
+ * than 5." etc.); `ja` follows the workspace Japanese spacing rule (a half-width space around
14
+ * ASCII alphanumerics, 「」 exempt).
15
+ * - between / notBetween receive `b` only for complete rules: an incomplete rule is inert and
16
+ * never produces a message.
17
+ *
18
+ * UI クロームロケール別の組込みデータ検証メッセージ — 規則に `errorMessage` が無いとき
19
+ * validateCellText が読む表。内部専用 (パッケージの barrel からは export しない — 公開の上書き手段は
20
+ * 規則毎の `errorMessage`)。純関数・React 非依存。
21
+ * 契約: SpreadsheetLocale 毎に凍結した ValidationMessageCatalog を 1 つ持ち、各テンプレートは
22
+ * dataValidation.ts が正規化済みの境界 (number / date は trim、textLength は文字列化、checkbox は既定
23
+ * 適用済み) を受け取り再正規化しない。テンプレートは (規則型, op) 毎の文全体 (日本語は境界が述語より
24
+ * 前に来るため、断片表では表現不能)。`en` は i18n 以前の英語要約とバイト単位で同一、`ja` は
25
+ * ワークスペースの和文スペース規則 (半角英数字の前後に半角スペース、「」内は対象外) に従う。
26
+ * between / notBetween の `b` は完全な規則でだけ渡る (不完全な規則は不活性でメッセージを作らない)。
27
+ */
28
+
29
+ import type { ValidationCompareOp, ValidationTextOp } from "./dataValidation.ts"
30
+ import type { SpreadsheetLocale } from "./labels.ts"
31
+
32
+ /**
33
+ * Whole-sentence templates of one ordered rule family (number / textLength / date), one per
34
+ * comparison op; `b` is the upper bound for between / notBetween and unused by the other six.
35
+ * 順序規則族 (number / textLength / date) 1 つ分の文全体テンプレート (比較 op 毎に 1 つ)。`b` は
36
+ * between / notBetween の上限で、他の 6 op では未使用。
37
+ */
38
+ type CompareTemplates = { readonly [K in ValidationCompareOp]: (a: string, b: string | undefined) => string }
39
+
40
+ /**
41
+ * The built-in verdict messages of one locale: a sentence for `list`, per-op templates for the
42
+ * three ordered families, per-op templates for `text` (the four needle ops take the needle; the
43
+ * two shape checks are fixed sentences), and templates for `checkbox` and `custom`.
44
+ * 1 ロケール分の組込み判定メッセージ。list の文、順序 3 族の op 別テンプレート、text の op 別
45
+ * テンプレート (needle 系 4 op は探索文字列を受け取り、形式検査 2 op は固定文)、checkbox と custom の
46
+ * テンプレート。
47
+ */
48
+ export type ValidationMessageCatalog = {
49
+ readonly list: string
50
+ readonly number: CompareTemplates
51
+ readonly textLength: CompareTemplates
52
+ readonly date: CompareTemplates
53
+ readonly text: {
54
+ readonly contains: (v: string) => string
55
+ readonly notContains: (v: string) => string
56
+ readonly startsWith: (v: string) => string
57
+ readonly endsWith: (v: string) => string
58
+ readonly isEmail: string
59
+ readonly isUrl: string
60
+ }
61
+ readonly checkbox: (checked: string, unchecked: string) => string
62
+ readonly custom: (formula: string) => string
63
+ }
64
+
65
+ /** Compile-time proof that the `text` templates are keyed by exactly the ValidationTextOp vocabulary (a new op without a message, or a stray key, turns tsc red). / text テンプレートのキーが ValidationTextOp 語彙と完全一致することの型水準の証明 (メッセージの無い新 op や余分なキーは tsc が赤)。 */
66
+ const textTemplatesMatchTextOps: [keyof ValidationMessageCatalog["text"]] extends [ValidationTextOp] ? ([ValidationTextOp] extends [keyof ValidationMessageCatalog["text"]] ? true : never) : never = true
67
+ void textTemplatesMatchTextOps
68
+
69
+ /** English wording of the four ordered ops of one family (the other four are shared). / 規則族 1 つ分の順序 4 op の英語の言い回し (残る 4 つは共通)。 */
70
+ type OrderWords = { readonly gt: string; readonly gte: string; readonly lt: string; readonly lte: string }
71
+
72
+ /** English wording for number / textLength messages. / number / textLength メッセージの英語の言い回し。 */
73
+ const NUMERIC_WORDS: OrderWords = { gt: "greater than", gte: "greater than or equal to", lt: "less than", lte: "less than or equal to" }
74
+
75
+ /** English wording for date messages. / date メッセージの英語の言い回し。 */
76
+ const DATE_WORDS: OrderWords = { gt: "after", gte: "on or after", lt: "before", lte: "on or before" }
77
+
78
+ /**
79
+ * English phrase for an ordered op with the family wording: "greater than 5", "between 1 and 9", ...
80
+ * 規則族の語彙を使った順序 op の英語句 ("greater than 5"、"between 1 and 9" など)。
81
+ *
82
+ * @param op - The comparison op / 比較 op
83
+ * @param a - The normalised lower (or only) bound / 正規化済みの下限 (または唯一の境界)
84
+ * @param b - The normalised upper bound for between / notBetween / between 系の正規化済み上限
85
+ * @param words - The family wording of the four ordered ops / 順序 4 op の規則族別の言い回し
86
+ * @returns The comparison phrase without subject or full stop / 主語と句点を含まない比較句
87
+ */
88
+ const comparePhrase = (op: ValidationCompareOp, a: string, b: string | undefined, words: OrderWords): string => {
89
+ switch (op) {
90
+ case "gt":
91
+ case "gte":
92
+ case "lt":
93
+ case "lte":
94
+ return `${words[op]} ${a}`
95
+ case "eq":
96
+ return `equal to ${a}`
97
+ case "neq":
98
+ return `not equal to ${a}`
99
+ case "between":
100
+ return `between ${a} and ${b}`
101
+ default:
102
+ // 残る順序 op は notBetween (語彙外の op は dataValidation.ts の assertRuleShape が先に拒否済み)
103
+ return `not between ${a} and ${b}`
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Builds the English templates of one ordered family as `${subject} ${phrase}.`, e.g.
109
+ * "Value must be a number greater than 5.".
110
+ * 順序規則族 1 つ分の英語テンプレートを `${subject} ${phrase}.` の形で構築 (例:
111
+ * "Value must be a number greater than 5.")。
112
+ *
113
+ * @param subject - The sentence head before the comparison phrase / 比較句の前に置く文頭
114
+ * @param words - The family wording of the four ordered ops / 順序 4 op の規則族別の言い回し
115
+ * @returns The frozen per-op templates / op 別の凍結テンプレート
116
+ */
117
+ const englishCompareTemplates = (subject: string, words: OrderWords): CompareTemplates => {
118
+ const sentence =
119
+ (op: ValidationCompareOp) =>
120
+ (a: string, b: string | undefined): string =>
121
+ `${subject} ${comparePhrase(op, a, b, words)}.`
122
+ return Object.freeze({
123
+ gt: sentence("gt"),
124
+ gte: sentence("gte"),
125
+ lt: sentence("lt"),
126
+ lte: sentence("lte"),
127
+ eq: sentence("eq"),
128
+ neq: sentence("neq"),
129
+ between: sentence("between"),
130
+ notBetween: sentence("notBetween"),
131
+ })
132
+ }
133
+
134
+ /** The English catalog — byte-identical to the pre-i18n built-in summaries. / 英語カタログ — i18n 以前の組込み要約とバイト単位で同一。 */
135
+ const EN: ValidationMessageCatalog = Object.freeze({
136
+ list: "Value must be one of the list items.",
137
+ number: englishCompareTemplates("Value must be a number", NUMERIC_WORDS),
138
+ textLength: englishCompareTemplates("Text length must be", NUMERIC_WORDS),
139
+ date: englishCompareTemplates("Value must be a date", DATE_WORDS),
140
+ text: Object.freeze({
141
+ contains: (v: string) => `Value must contain "${v}".`,
142
+ notContains: (v: string) => `Value must not contain "${v}".`,
143
+ startsWith: (v: string) => `Value must start with "${v}".`,
144
+ endsWith: (v: string) => `Value must end with "${v}".`,
145
+ isEmail: "Value must be a valid email address.",
146
+ isUrl: "Value must be a valid URL.",
147
+ }),
148
+ checkbox: (checked: string, unchecked: string) => `Value must be "${checked}" or "${unchecked}".`,
149
+ custom: (formula: string) => `Value does not satisfy the formula ${formula}.`,
150
+ })
151
+
152
+ /** The Japanese catalog — whole sentences with the bound first. / 日本語カタログ — 境界を先に置いた文全体。 */
153
+ const JA: ValidationMessageCatalog = Object.freeze({
154
+ list: "リストの項目のいずれかを入力してください。",
155
+ number: Object.freeze({
156
+ gt: (a: string) => `${a} より大きい数値を入力してください。`,
157
+ gte: (a: string) => `${a} 以上の数値を入力してください。`,
158
+ lt: (a: string) => `${a} より小さい数値を入力してください。`,
159
+ lte: (a: string) => `${a} 以下の数値を入力してください。`,
160
+ eq: (a: string) => `${a} に等しい数値を入力してください。`,
161
+ neq: (a: string) => `${a} 以外の数値を入力してください。`,
162
+ between: (a: string, b: string | undefined) => `${a} から ${b} までの数値を入力してください。`,
163
+ notBetween: (a: string, b: string | undefined) => `${a} から ${b} までの範囲外の数値を入力してください。`,
164
+ }),
165
+ textLength: Object.freeze({
166
+ gt: (a: string) => `${a} 文字より長いテキストを入力してください。`,
167
+ gte: (a: string) => `${a} 文字以上のテキストを入力してください。`,
168
+ lt: (a: string) => `${a} 文字より短いテキストを入力してください。`,
169
+ lte: (a: string) => `${a} 文字以下のテキストを入力してください。`,
170
+ eq: (a: string) => `${a} 文字のテキストを入力してください。`,
171
+ neq: (a: string) => `${a} 文字以外の長さのテキストを入力してください。`,
172
+ between: (a: string, b: string | undefined) => `${a} 文字から ${b} 文字までのテキストを入力してください。`,
173
+ notBetween: (a: string, b: string | undefined) => `${a} 文字から ${b} 文字までの範囲外の長さのテキストを入力してください。`,
174
+ }),
175
+ date: Object.freeze({
176
+ gt: (a: string) => `${a} より後の日付を入力してください。`,
177
+ gte: (a: string) => `${a} 以降の日付を入力してください。`,
178
+ lt: (a: string) => `${a} より前の日付を入力してください。`,
179
+ lte: (a: string) => `${a} 以前の日付を入力してください。`,
180
+ eq: (a: string) => `${a} の日付を入力してください。`,
181
+ neq: (a: string) => `${a} 以外の日付を入力してください。`,
182
+ between: (a: string, b: string | undefined) => `${a} から ${b} までの日付を入力してください。`,
183
+ notBetween: (a: string, b: string | undefined) => `${a} から ${b} までの範囲外の日付を入力してください。`,
184
+ }),
185
+ text: Object.freeze({
186
+ contains: (v: string) => `「${v}」を含む値を入力してください。`,
187
+ notContains: (v: string) => `「${v}」を含まない値を入力してください。`,
188
+ startsWith: (v: string) => `「${v}」で始まる値を入力してください。`,
189
+ endsWith: (v: string) => `「${v}」で終わる値を入力してください。`,
190
+ isEmail: "有効なメールアドレスを入力してください。",
191
+ isUrl: "有効な URL を入力してください。",
192
+ }),
193
+ checkbox: (checked: string, unchecked: string) => `「${checked}」または「${unchecked}」を入力してください。`,
194
+ custom: (formula: string) => `数式 ${formula} を満たす値を入力してください。`,
195
+ })
196
+
197
+ /**
198
+ * The built-in verdict messages per UI chrome locale (frozen at every level). Keyed by exactly the
199
+ * SpreadsheetLocale set, so validateCellText checks a locale by own-key membership here.
200
+ * UI クロームロケール別の組込み判定メッセージ (全階層で凍結)。キーは SpreadsheetLocale 集合と完全一致
201
+ * するため、validateCellText はここへの自身のキーの所属でロケールを検査する。
202
+ */
203
+ export const VALIDATION_MESSAGE_CATALOGS: Readonly<Record<SpreadsheetLocale, ValidationMessageCatalog>> = Object.freeze({ en: EN, ja: JA })
@@ -120,8 +120,8 @@ export type PopoverController = {
120
120
  isKindEnabled: (kind: PopoverKind) => boolean
121
121
  /** Opens a kind on the ACTIVE cell; RangeError when the kind is disabled or its capability undeclared. / active セルで kind を開く。無効 / 能力未宣言は RangeError。 */
122
122
  open: (kind: PopoverKind, options?: PopoverOpenOptions) => void
123
- /** Closes the interactive popover (idempotent) and returns focus to the sink. / 対話ポップオーバーを閉じ (冪等) シンクへフォーカスを返す。 */
124
- close: () => void
123
+ /** Closes the interactive popover (idempotent) and returns focus to the sink — unless `{ restoreFocus: false }`, which the host-driven count-shrink seam passes while focus sits outside the grid (a filter input) so the host's keystrokes are not stolen; the sink is re-armed when idle either way. / 対話ポップオーバーを閉じ (冪等) シンクへフォーカスを返す — `{ restoreFocus: false }` なら返さない (ホスト起点の行数縮小シームが、フォーカスがグリッド外 (絞り込み入力欄) にある間に渡す — ホストの打鍵先を奪わない)。idle なら再武装はどちらでも行う。 */
124
+ close: (options?: { restoreFocus: boolean }) => void
125
125
  /** The open interactive kind or null. / 開いている対話 kind か null。 */
126
126
  isOpen: () => PopoverKind | null
127
127
  /** rAF-coalesced hover entry for the note tip by cell (null = pointer left the cells); the body's own pointermove path queues the client point and hit-tests inside the frame. / note tip のセル指定ホバー入口 (rAF 合流。null = セル領域から離脱)。body の pointermove 経路は client 座標を積みフレーム内でヒットテストする。 */
@@ -458,20 +458,26 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
458
458
  const hideTip = useCallback((): boolean => hideTipNow(), [hideTipNow])
459
459
 
460
460
  /** Closes the interactive popover (idempotent) and returns focus to the sink. / 対話ポップオーバーを閉じ (冪等) フォーカスをシンクへ返す。 */
461
- const close = useCallback(() => {
462
- if (openKindRef.current === null) {
463
- return
464
- }
465
- const p = paramsRef.current
466
- openKindRef.current = null
467
- setContext(null)
468
- writeAnchors({ interactive: null })
469
- // フォーカス復帰 (スクロールさせない) + idle なら再武装 (編集セッションの値は守る)
470
- p.focusSink({ preventScroll: true })
471
- if (!p.isEditing()) {
472
- p.rearmSink()
473
- }
474
- }, [writeAnchors])
461
+ const close = useCallback(
462
+ (options?: { restoreFocus: boolean }) => {
463
+ if (openKindRef.current === null) {
464
+ return
465
+ }
466
+ const p = paramsRef.current
467
+ openKindRef.current = null
468
+ setContext(null)
469
+ writeAnchors({ interactive: null })
470
+ // フォーカス復帰 (スクロールさせない) — ホスト起点の閉鎖 (行数縮小) がグリッド外の打鍵先を守るときだけ省く。
471
+ // idle なら再武装 (編集セッションの値は守る) はどちらでも行う (rearm はフォーカス中立)
472
+ if (options === undefined || options.restoreFocus) {
473
+ p.focusSink({ preventScroll: true })
474
+ }
475
+ if (!p.isEditing()) {
476
+ p.rearmSink()
477
+ }
478
+ },
479
+ [writeAnchors],
480
+ )
475
481
 
476
482
  /** Places `ctx` on the interactive anchor (replacing whatever was open). / ctx を対話アンカーへ載せる (開いていたものは置換)。 */
477
483
  const present = useCallback(
@@ -11,7 +11,7 @@
11
11
  * の (c) は pressCell 1×1 を再構築するため等価でも新オブジェクト — ラッパーの構造 bail が受ける。
12
12
  */
13
13
 
14
- import { type CellCoord, type CellRange, coordEquals, makeRange, rangeContains, type SpreadsheetDirection } from "../model/cellCoord.ts"
14
+ import { type CellCoord, type CellRange, clampCoord, coordEquals, makeRange, rangeContains, type SpreadsheetDirection } from "../model/cellCoord.ts"
15
15
 
16
16
  /** Selection state (tail of ranges = the "current range"). / 選択状態 (末尾が現在レンジ)。 */
17
17
  export type SelectionState = {
@@ -38,6 +38,33 @@ const rangeEquals = (a: CellRange, b: CellRange): boolean => a.startRow === b.st
38
38
  */
39
39
  export const selectionStateEquals = (a: SelectionState, b: SelectionState): boolean => a.ranges.length === b.ranges.length && a.ranges.every((range, i) => rangeEquals(range, b.ranges[i])) && coordEquals(a.anchor, b.anchor) && coordEquals(a.active, b.active)
40
40
 
41
+ /**
42
+ * Clamps a selection into a NON-EMPTY grid whose counts shrank under it (same adapter — a
43
+ * filter / category / period change removed rows): every range corner, the anchor and the
44
+ * active cell are pulled onto the last row / column, and a range that lay entirely beyond the
45
+ * edge collapses onto it. Returns the INPUT object when nothing was out of bounds (identity
46
+ * contract → the §6.9-1 wrapper's Object.is bail = no notification) and keeps the identity of
47
+ * every range that was inside. Throws RangeError on an empty grid (the caller owns the empty
48
+ * path — 0 rows have no A1).
49
+ * 縮小した非空グリッドへ選択を畳む (同一アダプタ — 絞り込み / カテゴリ / 期間の変更で行が減った):
50
+ * 各レンジの隅・anchor・active を末尾行 / 列へ引き寄せ、丸ごと外にあったレンジは端へ潰れる。範囲外が
51
+ * 無ければ入力 object をそのまま返し (identity 契約 → §6.9-1 ラッパーの Object.is bail = 通知なし)、
52
+ * 範囲内のレンジは identity を保つ。空グリッドは RangeError (空経路は呼び手所有 — 0 行に A1 は無い)。
53
+ */
54
+ export const clampSelectionToGrid = (state: SelectionState, rowCount: number, colCount: number): SelectionState => {
55
+ const inside = (cell: CellCoord): boolean => cell.row < rowCount && cell.col < colCount
56
+ const rangeInside = (range: CellRange): boolean => range.endRow < rowCount && range.endCol < colCount
57
+ if (inside(state.anchor) && inside(state.active) && state.ranges.every(rangeInside)) {
58
+ return state
59
+ }
60
+ const clamp = (cell: CellCoord): CellCoord => clampCoord(cell, rowCount, colCount)
61
+ return {
62
+ ranges: state.ranges.map((range) => (rangeInside(range) ? range : makeRange(clamp({ row: range.startRow, col: range.startCol }), clamp({ row: range.endRow, col: range.endCol })))),
63
+ anchor: inside(state.anchor) ? state.anchor : clamp(state.anchor),
64
+ active: inside(state.active) ? state.active : clamp(state.active),
65
+ }
66
+ }
67
+
41
68
  /** Replaces the tail range, preserving identity when unchanged. / 現在レンジのみ置換 (等価なら入力を返す)。 */
42
69
  const replaceCurrentRange = (state: SelectionState, next: CellRange): SelectionState => {
43
70
  // identity 保存契約 (冒頭): 無変化なら新オブジェクトを作らない — §6.9-1 の Object.is bail の根拠