@aiquants/spreadsheet 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/README.md +219 -15
- package/dist/index.cjs +8 -15
- package/dist/index.d.cts +107 -5
- package/dist/index.d.ts +107 -5
- package/dist/index.js +4334 -4270
- package/dist/styles/spreadsheet.css +1 -1
- package/dist/styles/spreadsheet.standalone.css +1 -1
- package/dist/tsv.cjs +8 -0
- package/dist/tsv.d.cts +72 -0
- package/dist/tsv.d.ts +72 -0
- package/dist/tsv.js +80 -0
- package/dist/view-BkwCUzTl.cjs +1 -0
- package/dist/view-DycJZi_t.js +41 -0
- package/dist/view.cjs +1 -1
- package/dist/view.d.cts +31 -7
- package/dist/view.d.ts +31 -7
- package/dist/view.js +12 -12
- package/package.json +12 -1
- package/src/Spreadsheet.tsx +110 -14
- package/src/clipboard/tsv.ts +167 -72
- package/src/clipboard/useClipboard.ts +3 -2
- package/src/commands/useCommands.ts +43 -1
- package/src/grid/GridBody.tsx +51 -6
- package/src/header/resizeGates.ts +34 -0
- package/src/header/useHeaderResize.ts +83 -16
- package/src/index.ts +1 -1
- package/src/keyboard/shortcuts.ts +67 -0
- package/src/model/dataAdapter.ts +74 -0
- package/src/overlay/SelectionOverlay.tsx +22 -2
- package/src/styles/spreadsheet.css +193 -123
- package/src/tsv.ts +18 -0
- package/src/view.ts +67 -14
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,97 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@aiquants/spreadsheet` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.16.0 (unreleased)
|
|
6
|
+
|
|
7
|
+
TSV の往復を文字列のどの矩形行列でも厳密にし、拡大率の操作を等比の段に揃え、`@aiquants/spreadsheet/view` にホストが最初の画面を
|
|
8
|
+
グリッドと同じ数で描くための既定値を加える。
|
|
9
|
+
|
|
10
|
+
- **破壊的変更 — TSV の符号化 (§6.5.1)**: `serializeTsv` は、タブ・CR・LF を含む値に加えて `"` で始まる値も引用符で囲む (中の
|
|
11
|
+
`"` は二重化)。`parseTsv` / `measureTsv` (同じ 1 つの走査) が引用の読みを採るのは、引用が正しく閉じ (閉じの引用符の直後が
|
|
12
|
+
タブ・改行・終端)、かつ中身がタブ・CR・LF・`"` のどれかを含むときだけ。これで `parseTsv(serializeTsv(m))` は、少なくとも 1 × 1
|
|
13
|
+
の文字列のどの矩形行列 `m` でも `m` に等しい — 直したのはパッケージ自身の往復で、同じ記号 `"`・`"`・`Blue` の行が
|
|
14
|
+
1 つのセル `\t` と `Blue` に潰れ、A1 で開いた引用が A2 で閉じることがあった。Excel が書く素の `"<TAB>"<TAB>Blue` は本質的に
|
|
15
|
+
あいまいなので、従来どおり 2 つのセルに読む。Excel の素の出力は従来どおりに読む (引用だけで包まれた `"hi"` は文字どおり)。
|
|
16
|
+
読みが変わる素の値は、二重化した引用符を持つ引用のフィールドとそっくりな値だけ (文字どおりの `"a""b"` は `a"b` と読む)。
|
|
17
|
+
- **書き手は 2 つ**: クリップボードの文字は Excel の形、ファイルと保存する本文は厳密に往復する `serializeTsv`。新しい公開の
|
|
18
|
+
`serializeClipboardTsv` (barrel と `/tsv`) がクリップボードの書き手で、グリッドのコピーの `text/plain` も **Excel の形**
|
|
19
|
+
(タブ・CR・LF を含む値だけを囲み、中の `"` を二重化) で書くので、平文の貼り先には `"Important" note` がそのまま届く。この形は
|
|
20
|
+
`"` で始まるセルの往復が厳密でないため、グリッド自身の貼り付けは JSON の payload (`application/x-aqsp-clip`) を先に読み、
|
|
21
|
+
`text/plain` はほかの出どころのときだけ読む。
|
|
22
|
+
- **新機能 — `iterateTsvRows`**: `parseTsv` とちょうど同じ文法で行を 1 つずつ、幅を埋めずに出すジェネレータを `/tsv` と
|
|
23
|
+
barrel から公開する。途中で止めた受け手は取った行より先を実体化しないので、大きなファイルをセルの予算の下で読める。
|
|
24
|
+
`parseTsv` はこの行を埋めたもの、`measureTsv` も同じ 1 つの文法の走査を数える (文法の定義は 1 つ)。
|
|
25
|
+
- **新機能 — `measureTsv` の公開**: `parseTsv` が組む行 × 列を、組まずに返す寸法の走査を `@aiquants/spreadsheet/tsv` と barrel
|
|
26
|
+
から公開する。
|
|
27
|
+
- **破壊的変更 — 拡大率の段**: `stepSpreadsheetZoom(value, direction)` は 0.1 刻みでなく、新しい `SPREADSHEET_ZOOM_STEPS`
|
|
28
|
+
(−8 から 8 までの `2^(k/4)` を 100 分の 1 に丸めた 17 段: 0.25・0.3・0.35・0.42・0.5・0.59・0.71・0.84・1・1.19・1.41・
|
|
29
|
+
1.68・2・2.38・2.83・3.36・4) の、進む向きの次の段を返す。段の外の値 (ホストの 1.25) からも求め、結果は
|
|
30
|
+
入力を先に `clampSpreadsheetZoom` と同じく範囲へ切り、そこから一歩進む (9 からの縮小は 3.36、0.01 からの拡大は 0.3、端では
|
|
31
|
+
端に留まる)。グリッドの操作 (Alt+ホイールと Ctrl+Alt+= / -) も同じ段を進む。
|
|
32
|
+
- **新機能 — `@aiquants/spreadsheet/view` の既定値**: 既定の列幅 `SPREADSHEET_DEFAULT_COLUMN_WIDTH_PX` (100)、
|
|
33
|
+
`maxOperationCells` の既定 `SPREADSHEET_MAX_OPERATION_CELLS` (100,000)、列名の `columnIndexToName` (barrel と同じ関数) を
|
|
34
|
+
公開する。グリッドもここから読む (定義は 1 つ)。
|
|
35
|
+
- **修正 — グリッドの構造 (WCAG 1.3.1 / 4.1.2)**: `role="grid"` は本体の包み (`[data-aqsp-body]`) が持ち、先頭の凍結・
|
|
36
|
+
スクロール・末尾の凍結のペインを全て含む (スクロールのペインの中身は `role="rowgroup"`)。セルが `aria-selected` を持たないので
|
|
37
|
+
`aria-multiselectable` は付けない (選択は live region が読み上げる)。凍結した先頭の行がグリッドの外の
|
|
38
|
+
孤立した `row` になり、`aria-rowcount` がグリッドの外の行まで数えていた欠陥を直す。スクロールバーはポインタ専用
|
|
39
|
+
(`aria-hidden`・Tab の止まり先なし) に宣言し、グリッドの中に許されない子 (`scrollbar`・`button`) を作らない — キーボードの
|
|
40
|
+
スクロールはグリッド自身が持つ。
|
|
41
|
+
- **新機能 — `headerRows` / `labelledBy` / `readOnly`**: `headerRows` (既定 0、`frozenRows` 以下) は先頭の行のセルを
|
|
42
|
+
`role="columnheader"` と `data-aqsp-header-row` にする。`labelledBy` はグリッドとルートの `aria-labelledby` になり、組み込みの
|
|
43
|
+
名前 (`labels.rootAriaLabel`) の代わりになる (省略するとルートとグリッドの両方が組み込みの名前を持つ)。`readOnly` はグリッドと隠しの入力に `aria-readonly="true"` を付け、アダプタを全セルが読み取り専用のビュー
|
|
44
|
+
(`readOnlyAdapterView`) に包む — 書き込みは全て `onReadOnlyRejected` で拒まれ、セルの読み上げの `readOnly` は true。
|
|
45
|
+
切替はシート切替ではない (選択を保つ)。不正な `headerRows` / `labelledBy` は描画中の `RangeError`。
|
|
46
|
+
- **新機能 — 寸法のキーボード操作 (WCAG 2.1.1 / 2.1.3)**: 寸法は帯の 6 px の境界のドラッグでしか変えられなかった。
|
|
47
|
+
再束縛できる 5 つのコマンド `increaseColumnWidth` / `decreaseColumnWidth` (既定 Alt+Shift+→ / ←)・`increaseRowHeight` /
|
|
48
|
+
`decreaseRowHeight` (Alt+Shift+↓ / ↑)・`autoFitColumnWidth` (Alt+Shift+=) を加える。1 段は × / ÷ 2^(1/4) (整数 px、
|
|
49
|
+
少なくとも 1 px 動き 1 px を下回らない)、全スパン選択では帯のドラッグと同じく選んだ全てのトラックへ当てる。`readOnly` でも
|
|
50
|
+
使え、`enableResizeUI={false}` では使えない。新しい寸法は `formatResizeAnnouncement` (新しい prop、省略で無音) で読み上げ、
|
|
51
|
+
隠しの入力は実効のチョードを `aria-keyshortcuts` で示す。既定で束縛されるコマンドは 39 (41 チョード)。
|
|
52
|
+
- **新機能 — 色はすべて CSS 変数から**: グリッドが塗る全ての色 (セルの面・文字・格子の線・見出しの帯と文字・選択した見出し・
|
|
53
|
+
選択・リンク・フォーカス・分割線・指標・ポップオーバー) を、文書にある公開の変数 `--aqsp-*` から読む。明るい既定は `.aqsp-root`
|
|
54
|
+
の別名 `--_aqsp-*` の中で 1 度だけ書き、ホストは公開の変数を祖先 (`:root`・ダークのクラス) にもルートにも置ける。スタイル
|
|
55
|
+
シートは公開の名前を 1 つも宣言しない — 帯の寸法 (`--aqsp-row-header-width` / `--aqsp-col-header-height`) と強調帯
|
|
56
|
+
(`--aqsp-band-*`) も別名から読み、強調帯の別名は `highlightColors` prop が公開の名前をインラインで書くオーバーレイの要素で解く
|
|
57
|
+
(prop がホストの値に勝つ)。一覧と既定値は README の「Theming」節。
|
|
58
|
+
- **修正 — 意味を持つ印のコントラスト 3:1 (WCAG 1.4.11)**: コメントの三角 `#f97316` → `#eb6406` (2.80 → 3.3)、
|
|
59
|
+
凍結の分割線 `#9ca3af` → `#858e9d` (2.54 → 3.31)、参照とフィルの予告の緑 `#16a34a` → `#16a24a` (見出しの行の面で
|
|
60
|
+
2.99 → 3.03)、再表示の印は不透明に (見出しの帯で 2.86 → 4.7)、ポップオーバーの入力欄の境界は新しい
|
|
61
|
+
`--aqsp-input-border` (`#8b95a4`、3.03)。格子の線・帯の縁・ポップオーバーの枠・ノートの tip の枠は飾り (tip は面と影で
|
|
62
|
+
見分ける)。見出しの行 (`headerRows`) の面・文字・太さ (既定 700) も `--aqsp-header-row-*` で塗る。
|
|
63
|
+
- **修正 — 文字のコントラスト 7:1 (WCAG 1.4.6)**: 7:1 未満だった対を明度だけで直す — 見出しの文字 `#4b5563` → `#4a5361` (6.87 →
|
|
64
|
+
7.06)、選択した見出しの文字 `#1d4ed8` → `#1841b4` (5.49 → 7.01)、リンク `#1d4ed8` → `#163ba5` (6.70 → 9.53、選択の塗りの
|
|
65
|
+
下でも 7.05 以上)、控えめな文字 `#6b7280` → `#4e535e` (4.83 → 7.71)、拒否の文字 `#b91c1c` → `#a41919` (6.47 → 7.69)、主ボタンの面
|
|
66
|
+
`#2563eb` → `#134dce` (白の文字 5.17 → 7.05。ホバー `#173faf`)。全ての対を `designTokens.spec.ts` が CSS の文字から計算して
|
|
67
|
+
固定する。
|
|
68
|
+
- **変更 — 文字の大きさは rem の 1 つの基準から (U13)**: 全ての文字の大きさを `--aqsp-font-size` (既定 `0.8125rem` = 13px) から
|
|
69
|
+
比で導く。セルの中のエディタは粗いポインタで `max(1rem, 16px)` (iOS がページを拡大しない)。
|
|
70
|
+
- **寸法の操作の的 (WCAG 2.5.5 / 2.5.7)**: 帯の境界は 6px のストリップ (ドラッグ・ダブルクリックで autofit) のまま。境界の
|
|
71
|
+
44px の的は隣の見出しのラベルの押下を奪う (24px の行では次の行のラベルを丸ごと覆う) ので置かない。同等の操作は寸法の
|
|
72
|
+
キーボードのコマンドと `handle.executeCommand` で、AAA を要るホストはそれを動かす 44px の部品を置く (README の
|
|
73
|
+
「Pointer targets」節。フィルハンドルと再表示の印も同じ)。
|
|
74
|
+
- **DOM 属性**: `data-aqsp-header-row` の 1 種を追加 — リテラル census は 81 種 (dataset 直書きの同値抑止署名キー 4 種を
|
|
75
|
+
加えた総数は 85)。
|
|
76
|
+
- **公開面**: 公開面は `index.ts` の値 43 + 型 98 = 141 export (`measureTsv`・`serializeClipboardTsv`・`iterateTsvRows` を追加)。
|
|
77
|
+
- **検証 — 走行台帳 (census の錨は本未リリース節)**: 単体 / 統合 **97 ファイル 4,202 テスト** 全緑 —
|
|
78
|
+
0.15.0 出荷時の 94 ファイル 4,097 に v0.16 進行分 +105 (新規 `keyboardResize` 15・`gridSemantics` 14・`designTokens` 12、寸法の 5 コマンドで `shortcuts` +30 / `commands` +10 / `useCommands` +5、`tsv` の往復の性質・クリップボードの書き手・`iterateTsvRows` ほか +13、`view` の段と既定値 +5、0.15.0 節の出荷時凍結ピンで `hxCensus` +1)。`tsc --noEmit` / biome 0。
|
|
79
|
+
|
|
80
|
+
## 0.15.0 (2026-10-26)
|
|
81
|
+
|
|
82
|
+
グリッドがコピーと貼り付けに使う TSV の符号化を、グリッドを読み込まない副パス `@aiquants/spreadsheet/tsv` として公開する。
|
|
83
|
+
barrel も同じ関数を公開し続けるので、既存の import と描画・操作の動作は変わらない。
|
|
84
|
+
|
|
85
|
+
- **新機能 — `@aiquants/spreadsheet/tsv`**: `parseTsv` と `serializeTsv` (barrel と同じ関数で、定義は 1 つ) を、React も
|
|
86
|
+
`@aiquants/virtualscroll` も読まない入口から公開する (約 1 KB)。グリッドを遅延で読み込むホストが、最初の描画の時点で表の文字を
|
|
87
|
+
読める (サーバーで描く代わりの表と、それと同じクライアントの最初の描画)。barrel から `parseTsv` だけを読むと、束ねる道具は
|
|
88
|
+
グリッドの大半を落とせない (約 260 KB と React・virtualscroll の import が残る) ため、別の入口にした。
|
|
89
|
+
- **ビルド**: 入口は `index`・`view`・`tsv` の 3 つ。符号化のモジュールは `index` と `tsv` が共有する塊 (`tsv-<hash>.js` /
|
|
90
|
+
`.cjs`) に入り、どちらもそれを読む。
|
|
91
|
+
- **peer**: `@aiquants/virtualscroll` の peer の範囲は `^3.22.0` のまま (0.14.0 と同じ)。
|
|
92
|
+
- **検証 — 走行台帳 (census の錨は本未リリース節)**: 単体 / 統合 **94 ファイル 4,097 テスト** 全緑 —
|
|
93
|
+
0.14.0 出荷時の 93 ファイル 4,094 に v0.15 進行分 +3 (新規 `tsvEntry` 2、0.14.0 節の出荷時凍結ピンで `hxCensus` +1)。
|
|
94
|
+
barrel (`src/index.ts`) の export は変わらない。`tsc --noEmit` / biome 0。
|
|
95
|
+
|
|
5
96
|
## 0.14.0 (2026-10-26)
|
|
6
97
|
|
|
7
98
|
グリッドの固定の寸法と拡大率の規則を、グリッドを読み込まない副パス `@aiquants/spreadsheet/view` として公開する。グリッド自身も
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ deep-reach proof route.
|
|
|
36
36
|
- 📎 **Excel / Sheets clipboard interop with a lossless own payload** (ADR-14): copy writes `text/plain` (raw TSV), `text/html` (an interop table Excel and Sheets read — typed `x:num` / `mso-number-format` / `data-sheets-*` attributes) carrying the package's lossless JSON in a `data-aqsp-clip`
|
|
37
37
|
attribute, plus a custom MIME fast marker on the paste event; paste detects ONE source (own → Sheets → Excel → generic HTML → TSV), estimates before materialising, and Paste Special (values / formats / formulas / no borders / widths / validation / conditional formats / comments and notes, Excel
|
|
38
38
|
arithmetic operations, skip blanks, transpose) runs on both the keyboard (`Ctrl+Shift+V`, `Ctrl+Alt+V`) and the menu path (`handle.paste(options)` over `navigator.clipboard.read`); cut-move matches by clip id on both channels, including cross-sheet moves
|
|
39
|
-
- ⌨️ **Rebindable Sheets-style commands** (ADR-17):
|
|
39
|
+
- ⌨️ **Rebindable Sheets-style commands** (ADR-17): 75 `SpreadsheetCommand` literals (bold / italic / underline / strikethrough / alignment / number formats / borders / links / notes / comments / fill down / right / selection / date / time / hide / unhide / paste special, plus unbound extension
|
|
40
40
|
commands for colours, self-calc and highlight toggles) driven by ONE built-in table (`BUILTIN_ROWS`), a canonical chord grammar (`Ctrl+Alt+Shift+KEY`, letters by the produced character or the `code` letter, everything else by `event.code`), prop-time validation with `RangeError`s, and an
|
|
41
41
|
imperative `executeCommand` / `applyFormat` / `setBorders` / `setNote` / `addComment` / `setHyperlink` / `setValidation` handle group
|
|
42
42
|
- 💬 **Popover layer and highlight bands** (ADR-15): note editor + hover note tip, comment thread, hyperlink editor, validation list picker and validation message on two resident anchors positioned by the overlay (zero DOM for a v0.7 adapter with default props, `renderPopover` to replace the defaults, a `labels` bag folding the former `sinkAriaLabel` / `rootAriaLabel` props — see "Localization"), and Excel-style active row / column / intersection highlight bands (transform-only per scroll frame, `--aqsp-band-*` colours, controlled / uncontrolled `highlight` state)
|
|
@@ -446,17 +446,34 @@ away while the band is visible; with every trailing track hidden the scroll-side
|
|
|
446
446
|
|
|
447
447
|
## Accessibility model and trade-offs
|
|
448
448
|
|
|
449
|
+
**Structure.** One `role="grid"` element (the body wrapper) owns every row: the frozen leading pane, the scrolling pane
|
|
450
|
+
(its content element is the body `rowgroup`) and the frozen trailing pane all sit inside it, so `aria-rowcount` /
|
|
451
|
+
`aria-colcount` count every row and column and each `role="row"` has the grid as an ancestor. The grid does not declare
|
|
452
|
+
`aria-multiselectable`: no cell carries `aria-selected`, and the live region announces the selection instead
|
|
453
|
+
(`formatSelectionAnnouncement`). The scroll bars are pointer-only (`aria-hidden`, no Tab stop): the grid owns keyboard
|
|
454
|
+
scrolling, so they duplicate it, as native scroll bars do, and they never become children of the grid. The column-letter
|
|
455
|
+
and row-number bands are visual chrome outside the grid (`aria-hidden`). Three props complete the structure:
|
|
456
|
+
|
|
457
|
+
- `headerRows` (default 0, at most `frozenRows`): the leading rows whose cells are column headers — `role="columnheader"`,
|
|
458
|
+
painted by the stylesheet (`--aqsp-header-row-*`), so a screen reader names the column of every cell below them.
|
|
459
|
+
- `labelledBy`: the id of the host element that names the grid (a caption or heading) — `aria-labelledby` on the grid
|
|
460
|
+
and on the focusable root (the Escape landing point), instead of the built-in `labels.rootAriaLabel`. Without it, the
|
|
461
|
+
root and the grid both carry `labels.rootAriaLabel` as `aria-label`.
|
|
462
|
+
- `readOnly`: the whole grid is read-only — `aria-readonly="true"` on the grid and on the hidden input, every cell is
|
|
463
|
+
read-only whatever the adapter declares (every write goes to `onReadOnlyRejected` / `formatReadOnlyAnnouncement`, and the
|
|
464
|
+
cell announcement's `readOnly` is `true`), while selection, copy, scrolling, zoom and resizing stay available.
|
|
465
|
+
|
|
449
466
|
The grid renders cells as a **non-focusable virtualized surface** with a hidden 1px textarea ("sink") owning focus and
|
|
450
467
|
keyboard input — this is what makes IME composition, clipboard events, and 16M-cell virtualization coexist. The trade-offs:
|
|
451
468
|
|
|
452
|
-
-
|
|
469
|
+
- Keyboard focus stays in the hidden input, so a screen reader in focus mode does not move through the cells itself (browse mode reads the `role="grid"` structure above). Instead, cell and selection changes can be **announced** through a
|
|
453
470
|
debounced `aria-live="polite"` region — **strictly opt-in**: the package ships no built-in announcement strings (the `locale` catalogs cover UI chrome only), and the live region is
|
|
454
471
|
rendered only when you provide `formatCellAnnouncement` and/or `formatSelectionAnnouncement`. Omit both and the grid is
|
|
455
472
|
silent to assistive technology beyond its labels. The cell-announcement context carries both `text` (the raw canonical
|
|
456
473
|
`getCellText`) and `displayText` (the formatted string a sighted user sees — identical to `text` on unformatted cells),
|
|
457
474
|
so a formatter can announce `"87.0%"` rather than `"0.87"`.
|
|
458
475
|
- `labels.sinkAriaLabel` labels the input surface (its catalog value explains the Escape hand-off — in English by default, in
|
|
459
|
-
Japanese under `locale="ja"`); `labels.rootAriaLabel`
|
|
476
|
+
Japanese under `locale="ja"`); `labels.rootAriaLabel` names the grid's root (the Escape landing point) and the grid, unless `labelledBy` names both (`"Spreadsheet"` / `"スプレッドシート"`). Both
|
|
460
477
|
live in the `labels` prop (v0.8 — the former `sinkAriaLabel` / `rootAriaLabel` props were folded into it; together with the 19
|
|
461
478
|
popover strings they are the 21 package-owned keys, and the engine keys (every `VIRTUAL_SCROLL_LABEL_KEYS` entry) name the
|
|
462
479
|
grid scrollbars' chrome, such as the arrows and edge pills, and the empty state). The `locale` prop picks the catalog (`SPREADSHEET_LABEL_CATALOGS[locale]`), and an omitted or `undefined` key
|
|
@@ -470,8 +487,9 @@ keyboard input — this is what makes IME composition, clipboard events, and 16M
|
|
|
470
487
|
- Keyboard reachability is complete for the keymap below: every listed operation works without a pointer, and `Escape` from
|
|
471
488
|
the grid root returns focus to the page tab order (WCAG 2.1.2 — no keyboard trap). The grid never takes focus on mount
|
|
472
489
|
(the hidden input is armed focus-neutrally — WCAG 3.2.1 / 2.4.3): keyboard entry is the page tab order (Tab / Shift+Tab)
|
|
473
|
-
or a click, and re-entry always lands on the hidden input.
|
|
474
|
-
|
|
490
|
+
or a click, and re-entry always lands on the hidden input. Resizing has keyboard commands of its own (Alt+Shift+Arrow,
|
|
491
|
+
Alt+Shift+= — see "Resizing from the keyboard"), and filling has `fillDown` / `fillRight` / `fillSelection` (Ctrl+D / Ctrl+R /
|
|
492
|
+
Ctrl+Enter).
|
|
475
493
|
- The hidden input's `inputmode` is a **virtual-keyboard invitation**, not a mirror of the editing session — and not an idle
|
|
476
494
|
/ editing predicate. It starts as `inputmode="none"`, and for keyboard and IME users it stays `"none"` forever (F2, a
|
|
477
495
|
printable key, or a composition opens the editor without touching the attribute): a touch / pen tap does not summon a
|
|
@@ -506,6 +524,135 @@ keyboard input — this is what makes IME composition, clipboard events, and 16M
|
|
|
506
524
|
WebKit re-evaluates `inputmode` on the focused element and can show it (iPad + trackpad gets the keyboard). Hardware
|
|
507
525
|
keyboards and desktop IME are unaffected either way.
|
|
508
526
|
|
|
527
|
+
## Theming: CSS variables, contrast and type
|
|
528
|
+
|
|
529
|
+
Every colour the grid paints is read from a public `--aqsp-*` variable. The package never declares the public names: the
|
|
530
|
+
stylesheet reads each one exactly once, in an alias on `.aqsp-root` that also holds its light default
|
|
531
|
+
(`--_aqsp-surface: var(--aqsp-surface, #ffffff)`), and every rule paints the alias. A host therefore binds a dark scheme by
|
|
532
|
+
setting the public variables anywhere above or on the grid — `:root`, a `.dark` ancestor, or the grid's own `className` —
|
|
533
|
+
and every surface, line, label and popover follows; no colour is written anywhere else in the stylesheet (forced-colors
|
|
534
|
+
mode uses system colours only). `src/styles/designTokens.spec.ts` pins all of this from the CSS text.
|
|
535
|
+
|
|
536
|
+
```css
|
|
537
|
+
.dark {
|
|
538
|
+
--aqsp-surface: #111827;
|
|
539
|
+
--aqsp-text: #f3f4f6;
|
|
540
|
+
--aqsp-grid-line: #374151;
|
|
541
|
+
--aqsp-header-surface: #1f2937;
|
|
542
|
+
--aqsp-header-text: #e5e7eb;
|
|
543
|
+
/* … the rest of the table below … */
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
| Variable | Light default | Paints |
|
|
548
|
+
| :--- | :--- | :--- |
|
|
549
|
+
| `--aqsp-row-header-width` | `64px` | Width of the row-number band (= `SPREADSHEET_ROW_HEADER_WIDTH_PX`; a host that changes it sizes its box with the new value) |
|
|
550
|
+
| `--aqsp-col-header-height` | `24px` | Height of the column-letter band (= `SPREADSHEET_COLUMN_HEADER_HEIGHT_PX`; same caveat) |
|
|
551
|
+
| `--aqsp-font-size` | `0.8125rem` | The one type-size token (13 px at the default 16 px root); every other size is a ratio of it (12/13, 11/13, 16/13), and the in-cell editor uses it except under `(pointer: coarse)`, where it is `max(1rem, 16px)` so iOS does not zoom |
|
|
552
|
+
| `--aqsp-surface` | `#ffffff` | Cell surface, root, editor, popover and input backgrounds; the light half of the clip marquee |
|
|
553
|
+
| `--aqsp-text` | `#1f2937` | Cell text, editor text and caret, popover and button text |
|
|
554
|
+
| `--aqsp-grid-line` | `#e5e7eb` | Cell grid lines and the header cells' separators (decorative) |
|
|
555
|
+
| `--aqsp-border` | `#d1d5db` | Header band edges, the corner, popover frames and buttons (decorative) |
|
|
556
|
+
| `--aqsp-input-border` | `#8b95a4` | Popover text inputs — the input's only boundary cue, so 3:1 (WCAG 1.4.11) |
|
|
557
|
+
| `--aqsp-accent` | `#2563eb` | Active-cell frame, drag rectangle, focus rings, resize guide, fill handle, unhide markers, marquee |
|
|
558
|
+
| `--aqsp-header-surface` | `#f3f4f6` | Column-letter and row-number bands and the corner |
|
|
559
|
+
| `--aqsp-header-text` | `#4a5361` | Column letters and row numbers |
|
|
560
|
+
| `--aqsp-header-selected-surface` | `#dbeafe` | A header inside the selection |
|
|
561
|
+
| `--aqsp-header-selected-text` | `#1841b4` | The label of a header inside the selection |
|
|
562
|
+
| `--aqsp-header-row-surface` | `#f3f4f6` | Cells of the `headerRows` rows |
|
|
563
|
+
| `--aqsp-header-row-text` | `#1f2937` | Text of the `headerRows` cells |
|
|
564
|
+
| `--aqsp-header-row-font-weight` | `700` | Weight of the `headerRows` cells (not a colour; autofit measures with it) |
|
|
565
|
+
| `--aqsp-link` | `#163ba5` | Hyperlink cells (underlined) |
|
|
566
|
+
| `--aqsp-note-marker` | `#4b5563` | The note triangle |
|
|
567
|
+
| `--aqsp-comment-marker` | `#eb6406` | The comment triangle |
|
|
568
|
+
| `--aqsp-danger` | `#dc2626` | Invalid-cell triangle, subtract drag border, rejected accent, invalid input borders |
|
|
569
|
+
| `--aqsp-danger-fill` | `rgb(220 38 38 / 0.08)` | Subtract drag fill |
|
|
570
|
+
| `--aqsp-danger-text` | `#a41919` | Rejected hint and rejected message title |
|
|
571
|
+
| `--aqsp-danger-surface` | `#fef2f2` | Rejected message background |
|
|
572
|
+
| `--aqsp-selection-fill` | `rgb(59 130 246 / 0.18)` | Selection fill (drawn with `mix-blend-mode: multiply`) |
|
|
573
|
+
| `--aqsp-selection-border` | `rgb(37 99 235 / 0.85)` | Selection outline |
|
|
574
|
+
| `--aqsp-drag-fill` | `rgb(59 130 246 / 0.1)` | Drag rectangle fill |
|
|
575
|
+
| `--aqsp-reference` | `#16a24a` | Formula-reference rectangle and the pointing caret |
|
|
576
|
+
| `--aqsp-reference-fill` | `rgb(22 163 74 / 0.08)` | Formula-reference rectangle fill |
|
|
577
|
+
| `--aqsp-fill-preview` | `#16a24a` | Fill-handle preview border |
|
|
578
|
+
| `--aqsp-divider-color` | `#858e9d` | Freeze dividers (with `--aqsp-divider-width`, default `2px`) |
|
|
579
|
+
| `--aqsp-popover-shadow` | `0 1px 2px rgb(0 0 0 / 0.08), 0 8px 24px rgb(0 0 0 / 0.14)` | Popover shadow (a whole `box-shadow` value) |
|
|
580
|
+
| `--aqsp-note-tip-surface` | `#fffbeb` | Note tip background |
|
|
581
|
+
| `--aqsp-note-tip-border` | `#f59e0b` | Note tip frame (decorative: the tip is identified by its surface and shadow) |
|
|
582
|
+
| `--aqsp-note-tip-shadow` | `0 2px 8px rgb(0 0 0 / 0.12)` | Note tip shadow (a whole `box-shadow` value) |
|
|
583
|
+
| `--aqsp-title-text` | `#374151` | Popover titles |
|
|
584
|
+
| `--aqsp-muted-text` | `#4e535e` | Popover hints, counts, labels, times, the empty list and the close glyph |
|
|
585
|
+
| `--aqsp-strong-text` | `#111827` | Comment authors |
|
|
586
|
+
| `--aqsp-separator` | `#f3f4f6` | Lines between comments |
|
|
587
|
+
| `--aqsp-button-surface` | `#f9fafb` | Popover buttons |
|
|
588
|
+
| `--aqsp-hover-surface` | `#f3f4f6` | Hovered buttons, options and the close glyph |
|
|
589
|
+
| `--aqsp-hover-border` | `#9ca3af` | Hovered button border |
|
|
590
|
+
| `--aqsp-primary-surface` | `#134dce` | Primary buttons (Save, Post) |
|
|
591
|
+
| `--aqsp-primary-hover-surface` | `#173faf` | Hovered primary button |
|
|
592
|
+
| `--aqsp-primary-text` | `#ffffff` | Primary button text |
|
|
593
|
+
| `--aqsp-option-selected-surface` | `#dbeafe` | Active list option |
|
|
594
|
+
| `--aqsp-option-selected-text` | `#1e3a8a` | Active list option text |
|
|
595
|
+
| `--aqsp-band-row` | `#0084ff` | Row highlight band (the `highlightColors` prop writes it inline on the overlay, which wins over a host's value) |
|
|
596
|
+
| `--aqsp-band-column` | `#42ff4f` | Column highlight band (same writer) |
|
|
597
|
+
| `--aqsp-band-intersection` | `#e1ff00` | Intersection highlight band (same writer) |
|
|
598
|
+
| `--aqsp-band-opacity` | `0.2` | Opacity of the highlight bands (same writer) |
|
|
599
|
+
|
|
600
|
+
The highlight bands resolve their aliases on the overlay element, where the `highlightColors` prop writes the public
|
|
601
|
+
`--aqsp-band-*` names inline (so the prop wins over a host's value, and a host's value wins over the default);
|
|
602
|
+
`--aqsp-divider-width` (default `2px`) sets the freeze dividers' thickness. The stylesheet declares no public `--aqsp-*`
|
|
603
|
+
name itself.
|
|
604
|
+
|
|
605
|
+
**Non-text contrast (WCAG 1.4.11, 3:1).** The marks that carry meaning reach 3:1 against the default surface and the
|
|
606
|
+
header-row surface: accent 5.17 / 4.7, the selection outline 3.97 (over the surface),
|
|
607
|
+
the note marker 7.56 / 6.87, the comment marker `#eb6406` 3.3 / 3.0 (was `#f97316`, 2.80),
|
|
608
|
+
danger 4.83 / 4.39, the reference and fill-preview green `#16a24a` 3.33 / 3.03 (was `#16a34a`, 3.30 / 2.99),
|
|
609
|
+
the freeze divider `#858e9d` 3.31 / 3.0 (was `#9ca3af`, 2.54), the unhide markers at full opacity 4.7 on the
|
|
610
|
+
header band (they were 70 % opaque, 2.86), and the popover input border `#8b95a4` 3.03 (new; inputs used the decorative
|
|
611
|
+
`#d1d5db`, 1.47). Grid lines, band edges, popover frames and the note tip's frame are decorative (the tip is identified by its
|
|
612
|
+
surface and shadow).
|
|
613
|
+
|
|
614
|
+
**Contrast (WCAG 1.4.6, 7:1).** Every text / background pair of the light defaults is at least 7:1, measured with the WCAG 2
|
|
615
|
+
formula; the three pairs below 7:1 before v0.16 were re-solved by changing lightness only (the hue stays):
|
|
616
|
+
|
|
617
|
+
| Text on surface | Ratio |
|
|
618
|
+
| :--- | :--- |
|
|
619
|
+
| `--aqsp-header-text` `#4a5361` on `--aqsp-header-surface` `#f3f4f6` (was `#4b5563`, 6.87) | 7.06 |
|
|
620
|
+
| `--aqsp-header-selected-text` `#1841b4` on `--aqsp-header-selected-surface` `#dbeafe` (was `#1d4ed8`, 5.49) | 7.01 |
|
|
621
|
+
| `--aqsp-link` `#163ba5` on `--aqsp-surface` `#ffffff` (was `#1d4ed8`, 6.70) | 9.53 |
|
|
622
|
+
| `--aqsp-link` on `--aqsp-header-row-surface` `#f3f4f6` / under the selection fill on the surface / on the header row | 8.66 / 7.74 / 7.05 |
|
|
623
|
+
| `--aqsp-text` `#1f2937` on `--aqsp-surface` / `--aqsp-header-row-surface` / under the selection fill on each | 14.68 / 13.34 / 11.92 / 10.86 |
|
|
624
|
+
| `--aqsp-muted-text` `#4e535e` on `--aqsp-surface` / `--aqsp-danger-surface` / `--aqsp-note-tip-surface` (was `#6b7280`, 4.83) | 7.71 / 7.05 / 7.44 |
|
|
625
|
+
| `--aqsp-danger-text` `#a41919` on `--aqsp-surface` / `--aqsp-danger-surface` (was `#b91c1c`, 6.47) | 7.69 / 7.03 |
|
|
626
|
+
| `--aqsp-primary-text` `#ffffff` on `--aqsp-primary-surface` `#134dce` / `--aqsp-primary-hover-surface` `#173faf` (was `#2563eb`, 5.17) | 7.05 / 8.87 |
|
|
627
|
+
| `--aqsp-title-text` `#374151` / `--aqsp-strong-text` `#111827` on `--aqsp-surface` | 10.31 / 17.74 |
|
|
628
|
+
| `--aqsp-option-selected-text` `#1e3a8a` on `--aqsp-option-selected-surface` `#dbeafe` | 8.49 |
|
|
629
|
+
| `--aqsp-text` on the button / hover / note-tip / danger surfaces | 14.05 / 13.34 / 14.16 / 13.42 |
|
|
630
|
+
|
|
631
|
+
A host that binds its own colours keeps the 7:1 obligation for its values.
|
|
632
|
+
|
|
633
|
+
**Type.** Every type size derives from the one `--aqsp-font-size` token (`0.8125rem`, 13 px at the default root), so the
|
|
634
|
+
grid follows the user's default font size; the popover sizes are fixed ratios of it. The in-cell editor uses the same size
|
|
635
|
+
except under `(pointer: coarse)`, where it is `max(1rem, 16px)` — iOS zooms the page when an input below 16 CSS px takes
|
|
636
|
+
focus.
|
|
637
|
+
|
|
638
|
+
## Pointer targets (WCAG 2.5.5 / 2.5.7)
|
|
639
|
+
|
|
640
|
+
The grid's own resize affordances are small on purpose, and the grid does not try to make them 44 × 44 CSS px itself:
|
|
641
|
+
|
|
642
|
+
- **Strips.** Every column and row boundary carries a 6 px strip in the header bands (drag to resize, double-click to
|
|
643
|
+
autofit), and a boundary next to hidden tracks carries a 3 px unhide marker. A 44 px hit area on a boundary would cover
|
|
644
|
+
the neighbouring header labels (with 24 px rows, the whole label of the next row), so pressing a row or column label
|
|
645
|
+
next to the active boundary would resize instead of select; the strips stay inside their 6 px.
|
|
646
|
+
- **Fill handle.** The fill handle (opt-in through `enableFillHandle`) is a 6 px square at the selection's corner; every
|
|
647
|
+
pixel around it is a cell.
|
|
648
|
+
- **Equivalents.** Every one of these functions is reachable without a small target and without dragging: the keyboard
|
|
649
|
+
commands (`increaseColumnWidth` / `decreaseColumnWidth` / `increaseRowHeight` / `decreaseRowHeight` /
|
|
650
|
+
`autoFitColumnWidth`, `unhideRows` / `unhideColumns`, `fillDown` / `fillRight` / `fillSelection`) and
|
|
651
|
+
`handle.executeCommand(command)`. Target Size (2.5.5) and Dragging Movements (2.5.7) are met by the host: a page that
|
|
652
|
+
needs them provides 44 × 44 controls that run those commands (a toolbar, for instance).
|
|
653
|
+
- The column-letter band is 24 px tall (`SPREADSHEET_COLUMN_HEADER_HEIGHT_PX`) and the row-number band 64 px wide; cells and
|
|
654
|
+
header labels are as large as the host's rows and columns, and the zoom ladder enlarges every target with the grid.
|
|
655
|
+
|
|
509
656
|
## Context-menu constraint (right-click)
|
|
510
657
|
|
|
511
658
|
Right-clicking a cell moves the selection (Excel-style) and calls `onCellContextMenu` with the cell and the native event —
|
|
@@ -620,6 +767,9 @@ track, and paging moves by exact viewport-height pixel geometry, so page landing
|
|
|
620
767
|
| Ctrl+Enter | Fill every selected cell with the active cell's content | Run the strict validation gate on every selected cell's own rule (the first strict rejection keeps the session open, exactly like Enter), then write the session text to every selected cell in one transaction and close the session | Same | `fillSelection` |
|
|
621
768
|
| Ctrl+Semicolon / Ctrl+Shift+Semicolon | Write today's date (`YYYY-MM-DD`) / the current time (`HH:mm:ss`, local, `now` prop) into the **active cell only** | Insert at the caret | Same | `insertDate` / `insertTime` |
|
|
622
769
|
| Ctrl+Alt+Digit9 / Ctrl+Shift+Digit9 / Ctrl+Shift+Digit0 | Hide the selected rows / unhide rows / unhide columns (through the resize gates) | Native | Native | `hideRows` / `unhideRows` / `unhideColumns` |
|
|
770
|
+
| Alt+Shift+ArrowRight / Alt+Shift+ArrowLeft | Widen / narrow the active column by one step (× / ÷ 2^(1/4), whole px, at least 1 px of change, never below 1 px — a step never hides a column); with a full-span column selection every selected column takes the new size, as a strip drag does. Allowed under `readOnly` (a view change), unavailable with `enableResizeUI={false}` | Native | Native | `increaseColumnWidth` / `decreaseColumnWidth` |
|
|
771
|
+
| Alt+Shift+ArrowDown / Alt+Shift+ArrowUp | Make the active row taller / shorter by one step (same rule and fan-out) | Native | Native | `increaseRowHeight` / `decreaseRowHeight` |
|
|
772
|
+
| Alt+Shift+Equal (`=`) | Fit the active column to its content (the strip double-click's autofit; no-op while `renderCell` is set) | Native | Native | `autoFitColumnWidth` |
|
|
623
773
|
| *(unbound by default)* | Hide the selected columns — bind it yourself, e.g. `shortcuts={{ hideColumns: ["Ctrl+Alt+Shift+Digit9"] }}` | — | — | `hideColumns` |
|
|
624
774
|
| *(unbound by default)* | General (no value format) / all four borders | — | — | `formatGeneral` / `bordersAll` |
|
|
625
775
|
| *(unbound by default)* | Paste Special modes through the menu path (`handle.paste`) | — | — | `pasteFormats` / `pasteFormulas` / `pasteAdd` / `pasteSubtract` / `pasteMultiply` / `pasteDivide` |
|
|
@@ -639,9 +789,21 @@ untouched and `executeCommand` returns `false`. The editing columns consult only
|
|
|
639
789
|
(`bold` / `italic` / `underline` / `strikethrough` / `alignLeft` / `alignCenter` / `alignRight` / `formatNumber` / `formatTime` /
|
|
640
790
|
`formatDate` / `formatCurrency` / `formatPercent` / `formatScientific` / `fillSelection` / `insertDate` / `insertTime`); any other
|
|
641
791
|
command chord pressed while editing is native. The exported `DEFAULT_SHORTCUTS` (`{ [command]: readonly string[] }`, `[]` =
|
|
642
|
-
unbound:
|
|
792
|
+
unbound: 39 commands bound over 41 chords, 36 unbound) and `SPREADSHEET_RESERVED_SHORTCUTS` (the 63 exact chords of the
|
|
643
793
|
non-rebindable built-in rows consulted while idle) are derived from the same `BUILTIN_ROWS` table.
|
|
644
794
|
|
|
795
|
+
**Resizing from the keyboard (WCAG 2.1.1 / 2.1.3).** The five resize commands make the header strips' work keyboard-operable:
|
|
796
|
+
Alt+Shift+Arrow resizes the active column (Left / Right) or row (Up / Down) by one step and Alt+Shift+= fits the active column
|
|
797
|
+
to its content, through the strips' own fan-out and gates (`onOperationRejected("resize", …)` on a refusal). The chords were
|
|
798
|
+
chosen because nothing else claims them: the grid's own table has no Alt+Shift+Arrow row (Arrow, Shift+Arrow, Ctrl+Arrow and
|
|
799
|
+
Ctrl+Shift+Arrow navigate; Alt+ArrowDown opens the list picker; Alt+Shift+digits are the border presets), browsers bind Alt+Arrow
|
|
800
|
+
(history) but not Alt+Shift+Arrow, the screen readers' table commands use Ctrl+Alt+Arrow (NVDA, JAWS) or the VoiceOver keys
|
|
801
|
+
(Ctrl+Option), and Ctrl+Alt+Arrow also rotates the screen on some Windows drivers. On macOS, Option+Shift+Arrow extends a text
|
|
802
|
+
selection by word, which is why the commands are idle-only: in an editing session the keys keep their text meaning. Each
|
|
803
|
+
resize is announced through `formatResizeAnnouncement` (opt-in; it receives the command, the axis, the track index and the new
|
|
804
|
+
size in CSS px), and the hidden input lists the effective chords in `aria-keyshortcuts` (the ARIA spelling: `Control` for Ctrl,
|
|
805
|
+
`=` for Equal).
|
|
806
|
+
|
|
645
807
|
- **Grammar** (`parseChord`, canonical spelling only): `[Ctrl+][Alt+][Shift+]KEY` in that fixed order (Cmd counts as Ctrl).
|
|
646
808
|
`KEY` is a bare Latin letter `A`–`Z`, a digit by code (`Digit0`–`Digit9`, `Numpad0`–`Numpad9`), or a `KeyboardEvent.code` name
|
|
647
809
|
(`Semicolon`, `Backslash`, `IntlYen`, `IntlRo`, `Minus`, `Equal`, `F1`–`F12`, `ArrowDown`, `Enter`, `Escape`, `Space`, …). Letters
|
|
@@ -729,6 +891,7 @@ The grid's own UI chrome ships in English (`"en"`, the default) and Japanese (`"
|
|
|
729
891
|
| `defaultRowHeight` / `defaultColumnWidth` | `number` | Integer px (defaults 24 / 100). Per-column widths are applied through the imperative `resizeColumn` (the v0.3 `initialColumnWidth` prop was retired in v0.4). |
|
|
730
892
|
| `frozenColumns` | `number` | Freezes the first N columns at the left edge (v0.5; default 0, LIVE). Engine-validated (integer in [0, 128] — `RangeError` otherwise; values beyond `colCount` freeze every column). Frozen columns stay visible at any horizontal scroll; `scrollToCell` / keyboard ensure-visible treat them as horizontal no-ops. |
|
|
731
893
|
| `frozenRows` | `number` | Freezes the first N rows at the top edge (v0.6; default 0, LIVE — the row-axis twin of `frozenColumns`). Engine-validated (integer in [0, 128] — `RangeError` otherwise; values beyond `rowCount` freeze every row). Frozen rows stay visible at any vertical scroll; keyboard ensure-visible treats them as vertical no-ops. |
|
|
894
|
+
| `headerRows` | `number` | v0.16 — the leading rows whose cells are column headers (default 0): `role="columnheader"` and the stylesheet's header-row paint (`--aqsp-header-row-*`). An integer from 0 to `frozenRows`; anything else is a `RangeError` during render |
|
|
732
895
|
| `frozenTrailingColumns` | `number` | Freezes the **last** N columns at the right edge (v0.9; default 0, LIVE). Engine-validated pass-through, no second validation layer (integer in [0, 128] — `RangeError` otherwise); the effective count is clamped so the leading freeze wins the count space (`min(N, colCount − effectiveFrozenColumns)`). Trailing columns stay visible at any horizontal scroll — **except** when the leading + trailing extent exceeds the viewport: occluded trailing cells cannot be scrolled into view and keyboard focus draws no visible frame there (registered overlap consequence). `scrollToCell` / keyboard ensure-visible treat trailing targets as horizontal no-ops. An in-flight change of any of the four freeze props cancels every drag without restore. |
|
|
733
896
|
| `frozenTrailingRows` | `number` | Freezes the **last** N rows at the bottom edge (v0.9; default 0, LIVE — the row-axis twin of `frozenTrailingColumns`; a pinned totals row is the canonical use). Same engine validation and leading-wins clamp; trailing rows stay visible at any vertical scroll (same overlap exception); keyboard ensure-visible treats them as vertical no-ops. |
|
|
734
897
|
| `renderCell` | `(ctx: RenderCellContext) => ReactNode` — `RenderCellContext = { row; col; text; displayText }` (exported) | Custom cell rendering. **Must be identity-stable** (`useCallback`) — an inline arrow defeats the cell memo and re-renders every visible cell each pass. **Per-cell opt-out** (v0.12 — ADR-32): return `undefined` and that one cell falls back to the package default rendering, value-format engine included (only `undefined` opts out — `null`, `false` and any other ReactNode are custom output, i.e. an empty custom cell). `ctx.text` is the raw canonical string; `ctx.displayText` is the value-format display — the exact string the default rendering, announcements, the HTML clipboard and autofit share, so a custom cell that renders it can never drift from what gets copied. Autofit stays a whole-grid no-op while `renderCell` is set. |
|
|
@@ -744,6 +907,7 @@ The grid's own UI chrome ships in English (`"en"`, the default) and Japanese (`"
|
|
|
744
907
|
| `onUndoRequest` / `onRedoRequest` | `() => void` | `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` relays. |
|
|
745
908
|
| `formatCellAnnouncement` / `formatSelectionAnnouncement` | formatter | Opt-in screen-reader announcements (see above). |
|
|
746
909
|
| `formatCommandAnnouncement` | `(ctx: { command: SpreadsheetCommand; state: boolean \| null }) => string` | Opt-in (v0.8) live-region text after a successful format command; `state` is the toggle's post-state (`bold` / `italic` / `underline` / `strikethrough` / colour toggles), `null` for non-toggle commands. Also makes the live region render on its own. |
|
|
910
|
+
| `formatResizeAnnouncement` | `(ctx: { command: SpreadsheetCommand; axis: "row" \| "column"; index: number; size: number }) => string` | v0.16 — opt-in live-region text after a keyboard resize (`increaseColumnWidth` / `decreaseColumnWidth` / `increaseRowHeight` / `decreaseRowHeight` / `autoFitColumnWidth`): the active track's axis, index and new size in CSS px. Omit for silence (no built-in strings); shares the announcement debounce |
|
|
747
911
|
| `labels` | `SpreadsheetLabelOverrides` (any subset of the `SpreadsheetLabels` keys) | v0.8 — replaces the `sinkAriaLabel` / `rootAriaLabel` props (folded in) and names every UI chrome string: the engine keys shared with `@aiquants/virtualscroll` (every entry of the installed engine's `VIRTUAL_SCROLL_LABEL_KEYS`, such as `scrollUp`, `scrollDown`, `scrollToTop`, `noItems` — they reach the grid scrollbars and the empty state) and the 21 package keys (`sinkAriaLabel`, `rootAriaLabel`, `noteTitle`, `noteSave`, `noteEscapeHint`, `noteRejectedHint`, `noteViewerHint` (v0.10 — the note hint in the read-only viewer mode, which never promises a save), `commentTitle`, `commentReply`, `commentPost`, `commentCount` (string or `(n) => string`), `linkTitle`, `linkTextField`, `linkUrlField`, `linkOpen`, `linkSave`, `listTitle`, `listEmpty`, `listMore` (string or `(n) => string` — the validation-list picker mounts at most 500 options and announces the hidden-match count on this row), `messageTitle`, `popoverClose`). Omitted / `undefined` keys keep the value of the `locale` catalog (`SPREADSHEET_LABEL_CATALOGS[locale]`); an unknown key (including the removed `validationRejected` / `indicatorNote` / `indicatorComment` / `indicatorInvalid`) or a blank / non-string value throws a `RangeError` during render; `sinkAriaLabel` must mention `Escape` (`RangeError` during render otherwise). |
|
|
748
912
|
| `locale` | `SpreadsheetLocale` (`"en" \| "ja"`) | v0.13 — the UI chrome language, default `"en"`: selects the label catalog, the built-in validation messages (`onValidationRejected`'s `message` and the message popover) and the grid scrollbars' arrow names / empty state. Any other value (`"fr"`, `"ja-JP"`, `"EN"`, `""`, `null`) throws a `RangeError` during render, even on an empty grid — there is no language negotiation. Unrelated to `formats.locale`. See "Localization". |
|
|
749
913
|
| `shortcuts` | `ShortcutOverrides` = `{ [command]?: readonly string[] \| null }` | v0.8 — rebinds command chords (array = replace the default list, `null` = unbind); validated during render with `RangeError`s (see "Command shortcuts"). Identity-stable — consulted through a ref. |
|
|
@@ -762,6 +926,8 @@ The grid's own UI chrome ships in English (`"en"`, the default) and Japanese (`"
|
|
|
762
926
|
| `onPasteResolved` | `(outcome: PasteOutcome, channel: ClipChannel) => void` | v0.10 — paste-outcome summary. The menu channel mirrors `handle.paste(options)`’s resolve value 1:1 (read refusals and the write-time re-gate included); the event channel fires once per gesture that reaches the paste pipeline (incl. the image-only-clipboard `"empty"`). The drag-freeze consumption and a null `clipboardData` stay deliberately silent, and an empty grid attaches no paste listener at all (structurally unreachable). Cause-specific callbacks (`onClipboardUnavailable` / `onOperationRejected` / `onReadOnlyRejected`) fire FIRST — a `"rejected"` outcome can be the trailing summary of one of them. |
|
|
763
927
|
| `onReadOnlyRejected` | `(rejection: ReadOnlyRejection) => void` | v0.10 — the single read-only refusal channel: fired once per refused gesture, the adapter was never called (neither an `EditRejection` nor an operation limit). The edit variant carries the cell and the required `editSession: "none" \| "kept" \| "discarded"` fate; the operation variant carries the explicit 7-literal `op` (`"cut"` has two producers — the Ctrl+X arming refusal and the move-time source re-check) and the producer’s overlap-naive `cellCount`. Never fired when `isCellReadOnly` is undeclared. |
|
|
764
928
|
| `formatReadOnlyAnnouncement` | `(rejection: ReadOnlyRejection) => string` | v0.10 — opt-in live-region text for a read-only refusal (inherits the 200 ms announce debounce + rAF collapse, so autorepeat / IME refusal storms cannot spam the live region). Omit for silence (no built-in strings). |
|
|
929
|
+
| `readOnly` | `boolean` | v0.16 — the whole grid is read-only (default false): `aria-readonly="true"` on the grid and the hidden input; every cell is read-only whatever the adapter's `isCellReadOnly` says, so writes are refused through `onReadOnlyRejected` and the cell announcement's `readOnly` is true. Selection, copy, scrolling, zoom, resizing, hiding and highlighting stay available, and toggling it keeps the selection |
|
|
930
|
+
| `labelledBy` | `string` | v0.16 — the id of the element that names the grid: `aria-labelledby` on the `role="grid"` element and on the root, instead of the built-in `labels.rootAriaLabel` (which names both when it is omitted). A non-empty id without whitespace; anything else is a `RangeError` during render |
|
|
765
931
|
| `tapScrollCircleOptions` | `ScrollBarTapCircleOptions` | v0.10 — position / enable-disable tuning of the touch tap-scroll circle; an engine-validated pass-through onto VirtualGrid’s `scrollBarOptions.tapScrollCircleOptions` (the engine’s `resolveTapScrollCircleOptions` is the validation SSOT — no second layer here). LIVE prop. |
|
|
766
932
|
| `overscanRows` / `overscanCols` | `number` | v0.11 — per-axis overscan pass-through onto VirtualGrid’s `overscanRows` / `overscanCols` (undefined ⇒ the engine default 3). This slack is what absorbs the engine’s one-frame rendering-window lag during fast scrolling — raise it when fast flicks show blank bands. Overscan counts rows / columns (hidden 0-size tracks consume slots too), so the px slack per edge is overscan × row height / column width only where tracks are uniform and visible. The engine does not validate overscan (a negative value silently shrinks the rendering window), so THIS surface fail-fasts: a non-integer, negative or non-finite value throws a RangeError at render. Cost multiplies across the two axes, and the engine truncates rather than bounds: a window beyond its per-axis row budget or its total cell cap keeps the leading side and drops the trailing side — the row-budget case has no callback (the engine only logs a warning), the cell-cap case reports dropped columns through `onRenderTruncated`. Keep overscan to the slack you need (hundreds per side approach the budget on tall viewports). LIVE props. |
|
|
767
933
|
| `background` | `ReactNode` | v0.11 — background layer rendered behind the scroll-band rows inside the engine content element (the VirtualGrid `background` pass-through). VIEWPORT-PINNED: the engine translates only its row wrapper, so this layer does not follow the scroll offset — periodic row ruling placed here slides against the real rows. Suited to a static “content still arriving” texture for the overscan-exhausted band, not to row-aligned gridlines. It spans the full width of the scroll rows (the frozen-column region included — frozen columns live inside the scroll rows) but not the frozen row bands. **Identity-stable** like `renderCell`: an inline element pierces the GridBody memo boundary on every host re-render — hoist it or `useMemo` it. LIVE prop. |
|
|
@@ -819,41 +985,79 @@ aspects such as `columnWidths` — sizes may still have been applied).
|
|
|
819
985
|
|
|
820
986
|
## View geometry and zoom rules (`@aiquants/spreadsheet/view`)
|
|
821
987
|
|
|
822
|
-
`@aiquants/spreadsheet/view` publishes the grid's fixed geometry and its zoom
|
|
823
|
-
`@aiquants/virtualscroll
|
|
824
|
-
|
|
825
|
-
|
|
988
|
+
`@aiquants/spreadsheet/view` publishes the grid's fixed geometry, its defaults and its zoom ladder without loading the grid
|
|
989
|
+
(no React, no `@aiquants/virtualscroll`; its only dependency is the column-name codec, which imports nothing): a host can size
|
|
990
|
+
a box for the grid before the grid itself is loaded (a lazily imported grid, a server-rendered placeholder), paint a static
|
|
991
|
+
stand-in of the first screen, and drive the zoom from its own controls with exactly the numbers the grid uses. The grid reads
|
|
992
|
+
the same definitions.
|
|
826
993
|
|
|
827
994
|
| Export | Value / contract |
|
|
828
995
|
| :--- | :--- |
|
|
829
996
|
| `SPREADSHEET_COLUMN_HEADER_HEIGHT_PX` | `24` — the column-header band (`--aqsp-col-header-height`) |
|
|
830
997
|
| `SPREADSHEET_ROW_HEADER_WIDTH_PX` | `64` — the row-header band (`--aqsp-row-header-width`) |
|
|
831
998
|
| `SPREADSHEET_SCROLLBAR_SIZE_PX` | `12` — each scroll bar, reserved on both axes whether or not the grid scrolls |
|
|
999
|
+
| `SPREADSHEET_DEFAULT_COLUMN_WIDTH_PX` | `100` — the default of `defaultColumnWidth` |
|
|
1000
|
+
| `SPREADSHEET_MAX_OPERATION_CELLS` | `100_000` — the default of `maxOperationCells` |
|
|
1001
|
+
| `columnIndexToName(index)` | The column letters (`0` → `A`, `26` → `AA`); the same function as the barrel's |
|
|
832
1002
|
| `SPREADSHEET_ZOOM_MIN` / `SPREADSHEET_ZOOM_MAX` | `0.25` / `4` — the bounds of `zoom` |
|
|
1003
|
+
| `SPREADSHEET_ZOOM_STEPS` | The zoom ladder: `2^(k/4)` for every integer `k` from −8 to 8, rounded to hundredths — `0.25, 0.3, 0.35, 0.42, 0.5, 0.59, 0.71, 0.84, 1, 1.19, 1.41, 1.68, 2, 2.38, 2.83, 3.36, 4` (four steps double the zoom) |
|
|
833
1004
|
| `clampSpreadsheetZoom(value)` | Clamps into the bounds without quantizing; a non-finite value throws a `RangeError` |
|
|
834
|
-
| `stepSpreadsheetZoom(value, direction)` | The
|
|
1005
|
+
| `stepSpreadsheetZoom(value, direction)` | The next ladder step in the travel direction (`1` in, `-1` out), from any value, including one off the ladder (1.25 → 1.41 / 1.19). The input is clamped first (as `clampSpreadsheetZoom` does) and the step is taken from there: 9 zooms out to 3.36, 0.01 zooms in to 0.3, and a bound stays at the bound. A non-finite value throws a `RangeError`. The gestures (Alt+Wheel, Ctrl+Alt+= / -) step the same ladder |
|
|
835
1006
|
|
|
836
1007
|
At zoom 1 with uniform rows, a box that shows `n` rows exactly is
|
|
837
1008
|
`SPREADSHEET_COLUMN_HEADER_HEIGHT_PX + n × defaultRowHeight + SPREADSHEET_SCROLLBAR_SIZE_PX` px tall. Zoom buttons of a host that
|
|
838
1009
|
controls `zoom` step the same way the gestures do:
|
|
839
1010
|
|
|
840
1011
|
```tsx
|
|
841
|
-
import {
|
|
1012
|
+
import { stepSpreadsheetZoom } from "@aiquants/spreadsheet/view"
|
|
842
1013
|
|
|
843
|
-
const zoomIn = () => setZoom((zoom) =>
|
|
1014
|
+
const zoomIn = () => setZoom((zoom) => stepSpreadsheetZoom(zoom, 1))
|
|
844
1015
|
// <Spreadsheet zoom={zoom} onZoomChange={setZoom} … />
|
|
845
1016
|
```
|
|
846
1017
|
|
|
1018
|
+
## TSV codec (`@aiquants/spreadsheet/tsv`)
|
|
1019
|
+
|
|
1020
|
+
`@aiquants/spreadsheet/tsv` publishes `parseTsv`, `iterateTsvRows`, `serializeTsv`, `serializeClipboardTsv` and
|
|
1021
|
+
`measureTsv` — the codec the grid copies and pastes with — without loading the grid (no React, no `@aiquants/virtualscroll`; about 1 KB). A host that lazily imports the grid can
|
|
1022
|
+
still read the grid's text during the first render, for example to server-render a placeholder table of the rows the grid
|
|
1023
|
+
will show (the same markup on the client, so hydration matches). The barrel exports the same five functions (one
|
|
1024
|
+
definition), so code that already loads the grid keeps importing them from `@aiquants/spreadsheet`.
|
|
1025
|
+
|
|
1026
|
+
The round trip is exact: `parseTsv(serializeTsv(m))` deep-equals `m` for every rectangular matrix of strings of at least
|
|
1027
|
+
1 × 1, whatever the cells hold (tabs, CR, LF, CRLF, quotes, a cell that starts with `"`). Store tables with `serializeTsv`.
|
|
1028
|
+
|
|
1029
|
+
Two writers, two jobs: **clipboard text uses the Excel-compatible form, files and stored bodies use the strict,
|
|
1030
|
+
round-tripping `serializeTsv`.** The grid's own copy writes `text/plain` with `serializeClipboardTsv` (published, so a
|
|
1031
|
+
host's own copy control writes the same text), in **Excel's form**: it quotes only fields that hold a tab, CR or LF (doubling the `"` inside them), so a plain-text
|
|
1032
|
+
target receives `"Important" note` exactly as typed rather than `"""Important"" note"`. That form is not an exact round
|
|
1033
|
+
trip for cells that start with `"` (a raw `"<TAB>"<TAB>Blue` reads back as two cells, as it does in Excel), which is why the
|
|
1034
|
+
grid's own paste reads its JSON payload (the `application/x-aqsp-clip` flavour) first and reads `text/plain` only for
|
|
1035
|
+
other sources.
|
|
1036
|
+
|
|
1037
|
+
| Export | Contract |
|
|
1038
|
+
| :--- | :--- |
|
|
1039
|
+
| `parseTsv(text)` | A rectangular `string[][]`: one trailing line terminator dropped, empty text read as one empty cell (`[[""]]`), ragged rows padded with `""`; a field that starts with `"` is read as quoted only when the quote closes properly (the closing quote is followed by a tab, a line break or the end) and the unescaped content holds a tab, CR, LF or `"` — anything else is literal text, so Excel's raw output reads as Excel wrote it (`"hi"` stays `"hi"`). The one raw shape that reads unquoted is a value that looks exactly like a quoted field holding a doubled quote (`"a""b"` reads `a"b`) |
|
|
1040
|
+
| `serializeTsv(matrix)` | Rows joined with CRLF and a trailing CRLF; a field is quoted (its quotes doubled) when it holds a tab, CR or LF — the cells Excel quotes — or starts with `"`, the one other cell a reader could take for a quoted field; every other field, inner quotes included, is written verbatim |
|
|
1041
|
+
| `iterateTsvRows(text)` | A `Generator<string[]>` that yields the rows one at a time in exactly `parseTsv`'s grammar (same quoting rule, line terminators and final-terminator handling), **unpadded**; a consumer that stops early materialises nothing past the last row it took (preview a large file under a cell budget). `parseTsv` is these rows, right-padded |
|
|
1042
|
+
| `serializeClipboardTsv(matrix)` | The clipboard's `text/plain` (Excel's form): rows joined with CRLF and a trailing CRLF; a field is quoted (its quotes doubled) only when it holds a tab, CR or LF, so `"Important" note` is written as typed. Not an exact round trip for cells that start with `"`; the grid's own copy uses it, and a host's copy control should too |
|
|
1043
|
+
| `measureTsv(text)` | `{ rows, cols }` — the dimensions `parseTsv(text)` would build, from the same grammar walk, without building any row array (the paste limit check runs on it before anything is materialised) |
|
|
1044
|
+
|
|
1045
|
+
```tsx
|
|
1046
|
+
import { parseTsv } from "@aiquants/spreadsheet/tsv"
|
|
1047
|
+
|
|
1048
|
+
const rows = parseTsv(text) // ready before `import("@aiquants/spreadsheet")` resolves
|
|
1049
|
+
```
|
|
1050
|
+
|
|
847
1051
|
## Clipboard utilities (public)
|
|
848
1052
|
|
|
849
|
-
`parseTsv` / `serializeTsv` / `estimatePasteCellCount` / `expandPaste` are exported for building custom paste UIs and
|
|
1053
|
+
`parseTsv` / `serializeTsv` (also at `@aiquants/spreadsheet/tsv`) / `estimatePasteCellCount` / `expandPaste` are exported for building custom paste UIs and
|
|
850
1054
|
importers with the exact same Excel-compatible semantics the grid uses. `enumerateRangeCells`, `makeRange`,
|
|
851
1055
|
`rangeContains`, `rangeSize`, `columnIndexToName`, `columnNameToIndex` round out the toolkit. The named unions
|
|
852
1056
|
`SpreadsheetDirection` (`"up" | "down" | "left" | "right"` — the `findDataEdge` direction), `SpreadsheetOperation` (the
|
|
853
1057
|
`onOperationRejected` op), and `EditRejection` (the `onEditRejected` context — see "Editing, Undo, and rejection") are exported for
|
|
854
1058
|
consumer-side annotations.
|
|
855
1059
|
|
|
856
|
-
v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports
|
|
1060
|
+
v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 43 values and 98 types in total — every named type an exported declaration reaches is exported too, so nothing has to be extracted with `Awaited<ReturnType<>>` / `Extract<>`; `src/index.spec.ts` pins it):
|
|
857
1061
|
|
|
858
1062
|
- **Clip payload** — `serializeClipPayload(payload)` / `parseClipPayload(json)` (the lossless wire format `{ v: 1, rows, cols, clipId, formats, validations, cells, colWidths?, rowHeights?, wholeRows, wholeCols, conditionalFormats }`; the parser is total and returns `null` on ANY malformed input, the serializer validates the same way and throws a `RangeError`),
|
|
859
1063
|
`detectClipSource(flavours, channel)` (`"aqsp" | "sheets" | "excel" | "html" | "tsv" | "none"` — the ADR-14 detection over `{ types, getData }`), and the types `ClipPayload` / `ClipCell` / `ClipComment` / `ClipSource` / `ClipInfo` / `ClipFlavours` / `ClipChannel` (the `detectClipSource` parameter types).
|