@aiquants/spreadsheet 0.11.0 → 0.12.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.
@@ -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
@@ -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 の根拠