@aiquants/spreadsheet 0.8.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 +1103 -0
- package/LICENSE.md +155 -0
- package/README.md +817 -0
- package/dist/index.cjs +21 -0
- package/dist/index.d.cts +1960 -0
- package/dist/index.d.ts +1960 -0
- package/dist/index.js +7711 -0
- package/dist/styles/spreadsheet.css +2 -0
- package/dist/styles/spreadsheet.standalone.css +3 -0
- package/package.json +94 -0
- package/src/Spreadsheet.tsx +2313 -0
- package/src/clipboard/clipPayload.ts +1242 -0
- package/src/clipboard/fixtures/excel.fixture.ts +301 -0
- package/src/clipboard/fixtures/html.fixture.ts +90 -0
- package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
- package/src/clipboard/htmlInterop.ts +1694 -0
- package/src/clipboard/numberFormatCodec.ts +870 -0
- package/src/clipboard/pasteSpecial.ts +608 -0
- package/src/clipboard/tiling.ts +370 -0
- package/src/clipboard/tsv.ts +186 -0
- package/src/clipboard/useClipboard.ts +1154 -0
- package/src/commands/selfCalc.ts +279 -0
- package/src/commands/useCommands.ts +946 -0
- package/src/editor/useCellEditor.ts +708 -0
- package/src/grid/CellView.tsx +51 -0
- package/src/grid/GridBody.tsx +489 -0
- package/src/header/ColumnHeader.tsx +167 -0
- package/src/header/RowHeader.tsx +144 -0
- package/src/header/columnName.ts +31 -0
- package/src/header/resizeGates.ts +146 -0
- package/src/header/resizeStrips.ts +79 -0
- package/src/header/useHeaderResize.ts +598 -0
- package/src/header/useHeaderSelection.ts +454 -0
- package/src/index.ts +122 -0
- package/src/keyboard/compositionGuard.ts +53 -0
- package/src/keyboard/ensureCellVisible.ts +77 -0
- package/src/keyboard/shortcuts.ts +718 -0
- package/src/keyboard/useGridKeyboard.ts +940 -0
- package/src/model/axisModel.ts +186 -0
- package/src/model/cellAnnotations.ts +92 -0
- package/src/model/cellCoord.ts +114 -0
- package/src/model/cellFormat.ts +562 -0
- package/src/model/cellPaint.ts +537 -0
- package/src/model/conditionalFormat.ts +805 -0
- package/src/model/dataAdapter.ts +478 -0
- package/src/model/dataValidation.ts +542 -0
- package/src/model/formulaRefs.ts +984 -0
- package/src/model/frozenPartition.ts +100 -0
- package/src/model/labels.ts +228 -0
- package/src/model/viewStore.ts +124 -0
- package/src/overlay/SelectionOverlay.tsx +1425 -0
- package/src/overlay/useFillHandle.ts +432 -0
- package/src/popover/CommentPopover.tsx +109 -0
- package/src/popover/LinkPopover.tsx +108 -0
- package/src/popover/ListPopover.tsx +161 -0
- package/src/popover/MessagePopover.tsx +36 -0
- package/src/popover/NotePopover.tsx +106 -0
- package/src/popover/NoteTip.tsx +26 -0
- package/src/popover/PopoverLayer.tsx +210 -0
- package/src/popover/placement.ts +138 -0
- package/src/popover/usePopovers.ts +642 -0
- package/src/selection/selectionState.ts +239 -0
- package/src/selection/useAutoScroller.ts +226 -0
- package/src/selection/usePointDrag.ts +273 -0
- package/src/selection/useSelectionEngine.ts +687 -0
- package/src/styles/components.entry.css +10 -0
- package/src/styles/spreadsheet.css +1029 -0
- package/src/styles/standalone.entry.css +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
# @aiquants/spreadsheet
|
|
2
|
+
|
|
3
|
+
Excel-grade spreadsheet grid for React: multi-cell selection, TSV/HTML clipboard round-trips (Excel / Google Sheets interop with a lossless own payload since v0.8), in-place editing with IME support and formula reference pointing, drag-to-resize headers with hide/unhide and autofit, a fill handle,
|
|
4
|
+
declarative rich cells (formats / notes / comments / links / validation / conditional formats), rebindable Sheets-style command shortcuts and a popover layer — built on `@aiquants/virtualscroll`'s `VirtualGrid` and proven live on a 1,000,000 × 16,384 sheet plus a 1,000,000 × **10,000,000**
|
|
5
|
+
deep-reach proof route.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- ⚡ **1M rows × 10M columns**: both axes virtualized by `VirtualGrid` (v0.4 — quantized column anchor + residual transform, so layout coordinates never approach the browser's ~2^25 px LayoutUnit wall); DOM holds only visible cells + overscan, independent of selection size and data volume; a 10,000,000-column deep jump lands cells within ±1px
|
|
10
|
+
- 🖱️ **Excel selection semantics**: drag ranges, Shift/Ctrl extend & append, Ctrl+click/drag **subtraction** (deselect cells inside a selection), header row/column selection with Ctrl+click toggle-off, corner select-all, Ctrl/Shift+Space column/row banding, Tab/Enter cycling **across all selected ranges**, auto-scroll at viewport edges
|
|
11
|
+
- 📐 **Drag-to-resize headers, hide/unhide, autofit**: 6px boundary strips with a live guideline, drag-to-zero hides a track, 3px double-line markers restore hidden runs (including a hidden column A), double-click autofit, fan-out over full row/column selections
|
|
12
|
+
- 🖊️ **Fill handle (autofill)**: corner handle on single-range selections, bounding-box drag (single-axis bands or one-gesture diagonal L-fills) with dashed preview, Excel-compatible cyclic copy fill in one `applyEdits` transaction
|
|
13
|
+
- 📋 **Real clipboard, Excel-compatible TSV**: only cells containing tabs/newlines are quoted (a literal `"hi"` survives untouched), dual `text/plain` + `text/html` payloads (`mso-data-placement:same-cell`), tiling paste onto integer-multiple ranges, cut-move — and **both copy and cut arm an animated marching-ants marquee** (Excel-style; a copy's ants survive paste for repeated pasting, Escape or editing dismisses them; static under `prefers-reduced-motion`)
|
|
14
|
+
- ✏️ **In-place editing**: F2 / double-click (caret lands on the clicked character in real browsers) / type-to-replace, Alt+Enter multiline, full IME composition support (Safari and Chrome event orders both handled)
|
|
15
|
+
- 🧮 **Consumer-owned data**: a 4-member adapter (`rowCount` / `colCount` / `getCellText` / `applyEdits`) is the whole data contract — bring your own store, undo stack, and validation; an optional `findDataEdge` capability unlocks Excel Ctrl+Arrow data-edge jumps, and an optional `getCellFormat` capability (v0.7) adds built-in, totally fail-soft display formats —
|
|
16
|
+
value-aware auto-alignment (values that actually parse go right, labels stay left) plus number/currency/percent/scientific/date value formats over the display string (exact-decimal Intl formatting, interned formatters, timezone-proof pure-calendar dates), editing and copy still carrying the raw value — without a `renderCell`
|
|
17
|
+
|
|
18
|
+
- 🕳️ **Hidden tracks are first-class**: zero-size rows/columns render nothing, arrows and Shift-arrows jump a hidden run in one keystroke, Home/End land on visible tracks, and rendering stays fast across 100,000-column hidden runs
|
|
19
|
+
- 🛡️ **Never silently freezes**: every bulk operation (copy / cut / delete / paste / fill / resize fan-out / autofit) is size-estimated **before** any expansion and rejected past its `maxOperationCells`-derived ceiling with a typed callback
|
|
20
|
+
- ♿ **Screen-reader announcements (opt-in)**: provide the formatter props and cell/selection changes are announced via a debounced live region — the package ships no built-in strings
|
|
21
|
+
- 🧊 **Frozen columns & rows** (v0.5 / v0.6): `frozenColumns` pins the first N columns as a static left band and `frozenRows` pins the first N rows as a static top band — two-band headers, a Cartesian-product 4-quadrant clipped selection overlay, partition-aware hit-testing and ensure-visible on both axes, all riding the engine's anchor-free frozen bands
|
|
22
|
+
- 🎨 **Rich cells as capability declarations** (v0.8 — ADR-13): eight optional adapter reads (`getCellFormat` / `getCellFormula` / `getCellNote` / `getCellComments` / `getCellHyperlink` / `getCellValidation` / `getConditionalFormats` / `getRangeExtrema` + `evaluateFormula`) unlock the full cell
|
|
23
|
+
format model (value format, alignment, vertical alignment, wrap, background / font colour, bold / italic / underline / strikethrough, font family / size in pt, per-side borders with Excel-style ownership folding), note / comment / link indicators, paint-time validation marks and sheet-level
|
|
24
|
+
conditional formats (cell value / text / empty / colour scale / custom formula, 2-D bucket index, first-match-per-property precedence) — every undeclared aspect keeps the v0.7 DOM byte-identical, and writes go through a patch-typed `applyEdits(edits, context)`
|
|
25
|
+
(`CellEdit = { row, col } & CellPatch`, absent key = untouched, `null` = clear) with a transaction id and a discriminated `EditContext`
|
|
26
|
+
- 📎 **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`
|
|
27
|
+
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
|
|
28
|
+
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
|
|
29
|
+
- ⌨️ **Rebindable Sheets-style commands** (ADR-17): 70 `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
|
|
30
|
+
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
|
|
31
|
+
imperative `executeCommand` / `applyFormat` / `setBorders` / `setNote` / `addComment` / `setHyperlink` / `setValidation` handle group
|
|
32
|
+
- 💬 **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 24-key `labels` bag folding the former `sinkAriaLabel` / `rootAriaLabel` props), and Excel-style active row / column / intersection highlight bands (transform-only per scroll frame, `--aqsp-band-*` colours, controlled / uncontrolled `highlight` state)
|
|
33
|
+
- 🧭 **Formula reference pointing**: with `getCellFormula` declared, typing `=SUM(` and clicking, dragging or arrowing over cells inserts / replaces the reference token at the caret (`A1`, `A1:B2`, `A:A`, `1:1`), F4 cycles `$` anchors, F2 toggles the arm — the editing session, selection and sink position stay put
|
|
34
|
+
- 🔧 **TypeScript**: complete type definitions, ESM + CJS artifacts
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pnpm add @aiquants/spreadsheet @aiquants/virtualscroll
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`react` / `react-dom` (`^19.2`) and `@aiquants/virtualscroll` are peer dependencies.
|
|
43
|
+
|
|
44
|
+
> **Browser floor**: the built-in cell display formats (v0.7) require `Intl.NumberFormat` v3
|
|
45
|
+
> (string mathematical-value input + `signDisplay: "negative"`) — **Chrome/Edge 106+, Firefox 116+,
|
|
46
|
+
> Safari 15.4+**. On older engines the NUMERIC value formats (number / percent / currency /
|
|
47
|
+
> scientific) fail soft to the raw text (no crash); `date` (pure calendar math) and `text`
|
|
48
|
+
> never touch Intl and keep working. The package compiles against `lib: ES2023`.
|
|
49
|
+
|
|
50
|
+
## Basic Usage
|
|
51
|
+
|
|
52
|
+
> **Required:** import both stylesheets once — the grid relies on `@aiquants/virtualscroll`'s
|
|
53
|
+
> `.aqvs-*` classes for the scroll pane and this package's `.aqsp-*` classes for the grid chrome.
|
|
54
|
+
> For hosts without Tailwind, `@aiquants/spreadsheet/styles/spreadsheet.standalone.css` is a
|
|
55
|
+
> self-contained alternative that **already bundles** the virtualscroll CSS — import it *instead
|
|
56
|
+
> of* the pair above, never alongside (double inclusion breaks the cascade).
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { Spreadsheet, type SpreadsheetDataAdapter } from "@aiquants/spreadsheet"
|
|
60
|
+
import "@aiquants/virtualscroll/styles/virtualscroll.css"
|
|
61
|
+
import "@aiquants/spreadsheet/styles/spreadsheet.css"
|
|
62
|
+
|
|
63
|
+
const cells = new Map<string, string>()
|
|
64
|
+
|
|
65
|
+
const adapter: SpreadsheetDataAdapter = {
|
|
66
|
+
rowCount: 100_000,
|
|
67
|
+
colCount: 500,
|
|
68
|
+
getCellText: (row, col) => cells.get(`${row}:${col}`) ?? "",
|
|
69
|
+
applyEdits: (edits, context) => {
|
|
70
|
+
// context = { transaction, source, ... } — one call is one transaction (see "Editing, Undo, and rejection")
|
|
71
|
+
for (const edit of edits) {
|
|
72
|
+
// a CellEdit is a PATCH: an absent `text` leaves the text untouched (format-only edits carry none)
|
|
73
|
+
if (edit.text === undefined) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (edit.text === "") {
|
|
77
|
+
cells.delete(`${edit.row}:${edit.col}`)
|
|
78
|
+
} else {
|
|
79
|
+
cells.set(`${edit.row}:${edit.col}`, edit.text)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const Sheet = () => (
|
|
86
|
+
<div style={{ height: 600 }}>
|
|
87
|
+
<Spreadsheet adapter={adapter} />
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The host element must have a resolvable height — the grid fills its parent.
|
|
93
|
+
|
|
94
|
+
## The adapter contract
|
|
95
|
+
|
|
96
|
+
| Member | Contract |
|
|
97
|
+
| :--- | :--- |
|
|
98
|
+
| `rowCount` / `colCount` | Grid dimensions. Changing them requires swapping the adapter **object** (see below). |
|
|
99
|
+
| `getCellText(row, col)` | Pure, synchronous read. Called for visible cells during render, for **every cell of a copied/cut range** (bounded by `maxOperationCells`), for the source cells of a fill and the measured cells of an autofit (both bounded — see "Operation limits"), and for the active cell when announcements are enabled — a viewport-only lazy store is not sufficient. |
|
|
100
|
+
| `applyEdits(edits, context)` | One call = **one transaction** (a paste, a Delete, a fill, a cell commit, a format command, a self-calc, an annotation write each arrive as a single call). Each `CellEdit` is a **patch** (`{ row, col } & CellPatch` — `text?` / `format?` / `note?` / `comments?` / `hyperlink?` / `validation?`): an absent key leaves that aspect untouched, `null` clears it (`text` has no `null` — clear is `""`), `format` is always the WHOLE canonical `CellFormat` the package computed (store it verbatim and return it from `getCellFormat`), `comments` is the whole thread (entries with an `id` are kept verbatim, entries without one are new — YOU mint `id` / `author` / `createdAt`). Edits apply in array order with **per-key last-wins** duplicates (a later edit's keys override, its omitted keys keep the earlier value — cut-move relies on it). `context` is the discriminated `EditContext` (see "Editing, Undo, and rejection"): `{ transaction, source }` or, for a paste, `{ transaction, source: "paste", sameSheet, conditionalFormats }`. Throw to reject — the grid keeps or discards the in-flight state safely and fires `onEditRejected(error, rejection)`. |
|
|
101
|
+
| `findDataEdge(from, direction)` | **Optional capability.** When present, Ctrl+Arrow / Ctrl+Shift+Arrow jump to the Excel data edge instead of the grid edge; absence keeps the grid-edge jump (a capability declaration, not a value fallback). You implement the Excel rule — non-empty cell with a non-empty neighbor: end of the contiguous run; otherwise: the next non-empty cell in `direction`; none: the grid edge — because only the store can jump in O(occupied cells). Returns are validated **fail-fast before dispatch**: non-integer, out-of-axis, or off-row/column coordinates throw a `RangeError` that propagates uncaught. Reference implementation: the `/edit` demo store. |
|
|
102
|
+
| `getCellFormat(row, col)` | **Optional capability** (v0.7) — the cell's built-in display format: `{ align?, value? }`. `align` (`"left"`/`"center"`/`"right"`, undefined = **auto by the actual value**: text that parses under the cell's numeric/date format — or as a strict decimal with no format — goes right; labels stay left, so a `"Total"` in a currency column is never a right-aligned impostor) lands on the cell via inline `justify-content` + a `data-aqsp-align` attribute, and the in-place editor's textarea adopts the same alignment while editing (Excel parity); `value` (`number`/`percent`/`currency` (**`currency` code required** — no region default is assumed)/`scientific`/`date`/`text`) formats the **display** string. Absence keeps the v0.1 plain, left-aligned rendering. The engine is **exact**: the validated raw string is handed to `Intl.NumberFormat` **as a string** (NumberFormat v3 mathematical values — no binary-double round-trip, so percent `scale: false` boundaries and >2^53 integers stay exact), formatters are **interned** per option tuple (construction failures too — a broken column throws once, not per cell), negative zero is suppressed via `signDisplay: "negative"` (locale-proof), and dates are **pure calendar math** — no `Date` object, so host timezones, DST gaps, and two-digit-year remapping can never corrupt or reject a real date; with `decimals` omitted, `number` keeps every typed fraction digit (Excel General parity). Formatting is **totally fail-soft** — a non-numeric string (hex/binary like `"0x10"` stays raw), an invalid/rollover/mixed-separator date (`"2026-02-30"`, `"2026-09/02"`), or an out-of-range `Intl` option all render the raw text. It applies to **display only** — editing reads the raw `getCellText` (Excel parity), and copy/TSV carry the raw value too (a **deliberate deviation** from Excel — see the deviations table). Column autofit measures the **formatted** display string, and screen-reader announcements receive both the raw `text` and the formatted `displayText`. `renderCell` takes precedence over the built-in **value format** only; the resolved **alignment** is a cell property that still applies under `renderCell`. Return values are re-read only on a re-slice, so publish a live format change by bumping `dataVersion` (or swapping the adapter). Return a shared/interned `CellFormat` per column. The exported `formatCellValue(text, value)` is the same engine, reusable inside `renderCell`. Reference: the `/format` demo. **v0.8** widens `CellFormat` to the full rich model — `verticalAlign` (`"top"` / `"middle"` / `"bottom"`), `wrap`, `background`, `color`, `bold` / `italic` / `underline` / `strikethrough`, `fontFamily`, `fontSize` (pt), `borders` (`{ top?, right?, bottom?, left? }` of `{ style: "thin" \| "medium" \| "thick" \| "dashed" \| "dotted" \| "double" \| "hair"; color? }`, default colour `BORDER_DEFAULT_COLOR` = `#000000`) — painted as inline styles + `data-aqsp-valign` / `data-aqsp-wrap` state attributes; borders follow Excel ownership (a cell draws its own right / bottom, a neighbour's left / top folds onto them, own wins) and an unparseable colour or style skips that property alone. Formats are compared and interned by their **canonical form** (`canonicalizeFormat` — sorted keys, no `undefined` / `false`), and `mergeFormat(base, patch)` returns `base` by identity when nothing changes, so a command over 10,000 cells sharing one interned base writes ONE format object. This capability also gates every format command, `handle.applyFormat` / `setBorders`, the `formats` / `noBorders` paste modes and conditional formats. |
|
|
103
|
+
| `getCellFormula(row, col)` | **Optional capability** (v0.8) — the cell's formula source, which MUST start with `=` (any other string is a `RangeError` — fail-fast like `findDataEdge`); `undefined` = no formula. Declaring it makes the editor open the formula source instead of `getCellText` (Excel parity), enables **reference pointing** (see Keymap), `fillDown` / `fillRight` / `fillSelection` and the fill handle shift relative references (`shiftFormula`), self-calc commands wrap formulas instead of rewriting values, copy writes an origin-relative R1C1 `f` per cell and paste resolves it at the target (`fromR1C1`), and the `formulas` paste mode becomes available. Without it a `"="`-prefixed text is just text — the package never sniffs `=`. |
|
|
104
|
+
| `getCellNote(row, col)` | **Optional capability** (v0.8) — the note text or `undefined`. Unlocks the `data-aqsp-note` indicator, the hover note tip, the note popover (`Shift+F2` / `insertNote`), `handle.setNote` and the `commentsAndNotes` paste aspect. |
|
|
105
|
+
| `getCellComments(row, col)` | **Optional capability** (v0.8) — the thread as `readonly CellComment[]` (`{ id, author, text, createdAt }`, all stamped by you) or `undefined`; an empty thread shows no indicator. Unlocks `data-aqsp-comment`, the comment popover (`Ctrl+Alt+M` / `insertComment`), `handle.addComment` (sends `[...existing, { text }]` — you mint the id) and the comments paste aspect (pasted comments arrive id-less, all new). |
|
|
106
|
+
| `getCellHyperlink(row, col)` | **Optional capability** (v0.8) — the cell's href or `undefined`. Every read is a trust boundary: the raw URL is normalised through `normalizeHyperlink` (`http:` / `https:` / `mailto:` / `tel:` only), and a URL outside the allowlist is treated as link-less at every seam — never navigated, never exported into a clipboard flavour, never re-committed through `CellPatch.hyperlink`. Unlocks `data-aqsp-link`, the link popover (`Ctrl+K` / `insertLink`), `Alt+Enter` (`openHyperlink` → `onHyperlinkOpen` when declared — that row exists only while the active cell has a link, a link-less cell passes through; undeclared ⇒ the popover without an Open button), Ctrl / Meta+click opening the link when `onHyperlinkOpen` is declared (the selection stays frozen; a link-less cell keeps the append-range gesture), `handle.setHyperlink` (normalised through `normalizeHyperlink` — `http:` / `https:` / `mailto:` / `tel:` only), Delete / Backspace clearing the link (`hyperlink: null`) and the hyperlink paste aspect. Saving the popover with an empty display text and a valid URL writes the normalised href as the cell text — the href becomes the display text (Sheets / Excel parity, never an invisible link on a blank cell; clearing the URL too erases the link without touching the text). |
|
|
107
|
+
| `getCellValidation(row, col)` | **Optional capability** (v0.8) — the cell's `DataValidationRule` (`list` / `number` / `textLength` / `date` / `text` / `checkbox` / `custom`, each with `strict?` / `inputMessage?` / `errorMessage?`) or `undefined`; return a **stable rule object per cell** (list Sets are interned by identity). A commit that changes the text is validated (`validateCellText`): a non-strict rule accepts the value and paints the `data-aqsp-invalid` mark, a `strict: true` rule refuses the commit (`onValidationRejected` + the message popover — the editing-column Ctrl+Enter `fillSelection` runs the same gate on every selected cell's own rule before its single `applyEdits`), a blank is always valid, a half-written rule (unparseable bound, missing `b`) is inert, and `custom` rules are evaluated only at commit through `evaluateFormula` (never at paint). Unlocks the list picker (`Alt+ArrowDown`), the input-message popover on active-cell change, `handle.setValidation` and the validation paste aspect. |
|
|
108
|
+
| `getConditionalFormats()` | **Optional capability** (v0.8) — the sheet's `ConditionalFormatRule[]` in priority order (`{ id, ranges, condition, style, stopIfTrue? }`; conditions `cellValue` / `text` / `empty` / `notEmpty` / `colorScale` / `customFormula`; style = background / color / bold / italic / underline / strikethrough). **Requires `getCellFormat`** (declaring it alone throws a `RangeError` at mount). Keep the array identity stable and publish changes through `dataVersion`; the grid rebuilds a lazy 2-D 256 × 256 bucket index per (rules identity, render version) and, inside each bucket, a lazily materialised per-column slice, so per-cell cost is O(rules intersecting the cell's column within its 256-row band) — for whole-column rules exactly the rules on that column, whatever the sheet width (a sheet of ≤ 256 columns has a single column band, so the bucket alone would be the whole rule set). Every true rule contributes, an earlier rule wins per property, `stopIfTrue` ends the walk. Pasted rules arrive as `context.conditionalFormats` (`ConditionalFormatRuleInput` with `sourceRuleId` — you mint the new id). |
|
|
109
|
+
| `getRangeExtrema(range)` / `evaluateFormula(formula, cell)` | **Optional capabilities** (v0.8) consumed by the rules above: `getRangeExtrema` returns `{ min, max }` (or `null` = no numeric value) for a `colorScale` point without `value` — called once per rule range per render version and memoised; `evaluateFormula` receives a `customFormula` verbatim (no reference shifting — you own relative semantics) and a `custom` validation formula at commit, truth = `true` or a finite non-zero number. Undeclared ⇒ the rules that need them are skipped (nothing is substituted). |
|
|
110
|
+
|
|
111
|
+
- **Adapter identity is the sheet identity.** Replacing the adapter object means "sheet switch": selection resets to A1, any in-flight drag, editing session and open popover are terminated (value-discarding, exactly like Excel switching sheets — nothing is ever committed across a swap), an armed
|
|
112
|
+
**copy** is discarded, and an armed **cut** is kept as a *foreign* clip (`onClipChange({ kind: "cut", range, foreign: true })`, its marquee hidden) so the next `Ctrl+V` on the new sheet is a **cross-sheet move** — the target sheet is written first, then the source sheet is cleared under the same
|
|
113
|
+
transaction id (see "Cut matching rule").
|
|
114
|
+
- **External writes** (your toolbar Undo, collaborative updates) are published by incrementing the `dataVersion` prop. This re-renders visible cells, rebuilds the conditional-format index, **and cancels any armed clip** — otherwise a stale cut could "move" cells whose content has changed underneath.
|
|
115
|
+
|
|
116
|
+
## Editing, Undo, and rejection
|
|
117
|
+
|
|
118
|
+
The package owns no history. `Ctrl+Z` / `Ctrl+Y` (and `Ctrl+Shift+Z`) are **relayed** to `onUndoRequest` / `onRedoRequest`; apply the inverse patches to your store and bump `dataVersion`. The `/edit` route of the repository demo (`demo/src/EditApp.tsx` — source checkout only, not in the npm tarball) is the reference implementation: a `Map`-backed store with an inverse-patch undo/redo stack, ~60 lines.
|
|
119
|
+
|
|
120
|
+
`applyEdits` may throw to reject an edit. Every rejection fires `onEditRejected(error, rejection)` exactly once, where `rejection` is an
|
|
121
|
+
`EditRejection` (exported) — a discriminated union naming the write path that was refused and, for an in-cell commit, what happened
|
|
122
|
+
to the editing session:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
type EditRejection =
|
|
126
|
+
| { source: "edit"; editSession: "kept" | "discarded" }
|
|
127
|
+
| { source: "paste" | "fill" | "delete" | "format" | "calc" | "annotate" } // the SpreadsheetOperation write names onOperationRejected uses
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Every write reaches the adapter as `applyEdits(edits, context)` (v0.8 — ADR-16). `edits` are **patches** and `context` is the exported
|
|
131
|
+
discriminated `EditContext`:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
type CellPatch = { text?: string; format?: CellFormat | null; note?: string | null; comments?: readonly CellCommentInput[] | null; hyperlink?: string | null; validation?: DataValidationRule | null }
|
|
135
|
+
type CellEdit = { row: number; col: number } & CellPatch // absent key = untouched, null = cleared (text: "" clears)
|
|
136
|
+
type EditContext =
|
|
137
|
+
| { transaction: number; source: "edit" | "fill" | "delete" | "format" | "calc" | "annotate" } // PlainEditContext
|
|
138
|
+
| { transaction: number; source: "paste"; sameSheet: boolean; conditionalFormats: readonly ConditionalFormatRuleInput[] } // PasteEditContext
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`transaction` is a per-grid-instance id that increases strictly — one per `applyEdits` call, allocated **before** the call (a rejected
|
|
142
|
+
call consumes its id too), so a cross-sheet cut-move (target write on sheet B, then source clear on sheet A) can be folded into one
|
|
143
|
+
Undo entry by the id. An empty transaction (no edits and no conditional formats) never reaches the adapter. `source` names the
|
|
144
|
+
gesture: `"edit"` (cell commit, list pick, `insertDate` / `insertTime` while idle), `"paste"` (paste, cut-move, Paste Special — with
|
|
145
|
+
`sameSheet` = the armed payload came from this adapter, and `conditionalFormats` = the pasted rules translated per tile, `[]` when
|
|
146
|
+
none), `"fill"` (fill handle, `fillDown` / `fillRight` / `fillSelection`), `"delete"` (Delete / Backspace), `"format"` (format commands,
|
|
147
|
+
`applyFormat` / `setBorders`), `"calc"` (`multiplyBy100` … `signReversal`), `"annotate"` (note / comment / link / validation writes). The
|
|
148
|
+
`/styles` demo store (`demo/src/pages/styles/richStore.ts`) is the reference for a patch-applying store that mints comment ids and
|
|
149
|
+
folds a transaction's inverse patches into one Undo entry.
|
|
150
|
+
|
|
151
|
+
- **In-grid commits** (Enter, Tab / Shift+Tab, PageUp / PageDown, clicking or right-clicking another cell, or a primary-button
|
|
152
|
+
press on a header / corner / scrollbar band / resize strip — a secondary-button press on those surfaces is a native-menu
|
|
153
|
+
passthrough that neither commits nor fires `onEditRejected`) →
|
|
154
|
+
`{ source: "edit", editSession: "kept" }`: the editing session stays alive with the typed value **and focus stays in the
|
|
155
|
+
editor**, so the user can fix the value or press Escape to cancel. A rejected pointer commit (clicking another cell or a header
|
|
156
|
+
while editing) also cancels the browser's default mousedown focus move, so focus is never stranded on the grid container.
|
|
157
|
+
- **Focus leaving the grid** (clicking an element outside the grid, or focus moved outside by script — Tab / Shift+Tab while
|
|
158
|
+
editing are in-grid commits, not an exit) → `{ source: "edit", editSession: "discarded" }`: a session must never survive
|
|
159
|
+
without focus, so the grid discards it as this rejection completes — the callback runs first; once it returns, the typed value
|
|
160
|
+
is gone, the session is closed, and the hidden input is re-armed immediately, **without** moving focus: it stays wherever the
|
|
161
|
+
focusout left it — on the element the user chose, or still on the hidden input after a window blur (alt-tab; a null
|
|
162
|
+
`relatedTarget` takes the same discard path). Do **not** tell the user to "press Escape" for this case: the session is already
|
|
163
|
+
gone by the time they could act, and an idle Escape hands focus out of the grid (the WCAG 2.1.2 exit), which reads as "the
|
|
164
|
+
sheet loses focus and the cell can't be returned to a non-editing state".
|
|
165
|
+
- **Paste, cut-move, fill, Delete / Backspace, format commands, self-calc, annotation writes** →
|
|
166
|
+
`{ source: "paste" | "fill" | "delete" | "format" | "calc" | "annotate" }`: no editing session is
|
|
167
|
+
involved; the selection and any armed copy / cut clip are left exactly as they were. These are the `SpreadsheetOperation`
|
|
168
|
+
**write** names — the same ones `onOperationRejected` reports for the same gesture, so one label map can serve both
|
|
169
|
+
callbacks. A cut-move is the Ctrl+V gesture and is reported as `"paste"` by both callbacks (Ctrl+X itself only serializes,
|
|
170
|
+
so `"cut"` and `"copy"` never write and `"resize"` is geometry — none of them can appear here). A rejected annotation
|
|
171
|
+
write from a popover keeps the popover open in its `rejected` state (Ctrl+Enter retries, Escape closes without saving).
|
|
172
|
+
The union is spelled out
|
|
173
|
+
literally (the d.ts rollup cannot follow `Extract<SpreadsheetOperation, …>`), and its exact equality with the write
|
|
174
|
+
members is machine-sealed by a type-parity guard (`src/model/dataAdapter.spec.ts`) that fails `tsc` on a missing write
|
|
175
|
+
member, a foreign name, or a non-write member leaking in.
|
|
176
|
+
- **Strict validation** (`getCellValidation` rule with `strict: true`) refuses a changed commit *before* `applyEdits`: no
|
|
177
|
+
`onEditRejected` fires — `onValidationRejected({ cell, rule, text, message })` does, the message popover opens with the
|
|
178
|
+
verdict, and the editing session follows the same kept / discarded path as an adapter rejection. The editing-column
|
|
179
|
+
Ctrl+Enter (`fillSelection`) runs the same gate on **every selected cell's own rule** (rules differ per cell) before its
|
|
180
|
+
single `applyEdits`: the first strict rejection keeps the session open and writes nothing anywhere, exactly like Enter.
|
|
181
|
+
Idle fills, the fill handle, paste and self-calc are not validated — it is a commit-time gate, as in Excel.
|
|
182
|
+
|
|
183
|
+
An unchanged commit (opening the editor and leaving without changing the text) never reaches `applyEdits`, so a plain focus-out on a
|
|
184
|
+
read-only sheet produces no rejection at all. The demo's `demo/src/components/editRejection.ts` (`describeEditRejection`) is a
|
|
185
|
+
reference for wording a notice from this context.
|
|
186
|
+
|
|
187
|
+
## Operation limits (`maxOperationCells`, default 100,000)
|
|
188
|
+
|
|
189
|
+
Every bulk operation is counted **before** any memory is allocated or any cell is read. Over-limit and out-of-bounds
|
|
190
|
+
operations call `onOperationRejected(op, cellCount, reason)`
|
|
191
|
+
(`op: "copy" | "cut" | "delete" | "paste" | "fill" | "resize" | "format" | "calc" | "annotate"`, `reason: "limit" | "bounds"`) and do
|
|
192
|
+
nothing — wire it to a toast. A cut paste that would clip at the grid edge is rejected entirely (`"bounds"`) rather than silently
|
|
193
|
+
losing the clipped cells, and the cut stays armed so the user can retry at a valid target. Every v0.8 selection write (format
|
|
194
|
+
commands / `applyFormat` / `setBorders` → `"format"`, self-calc → `"calc"`, `setValidation` → `"annotate"`) counts the selected cells
|
|
195
|
+
against the same ceiling before reading a single cell; pasted HTML is estimated from its markup (rows × the widest row's
|
|
196
|
+
Σ`colspan`, an upper bound) before a `DOMParser` is even constructed, and an own-payload paste from its 512-byte header.
|
|
197
|
+
|
|
198
|
+
**What `cellCount` counts — and the per-operation ceilings.** The count definitions are deliberately asymmetric: each
|
|
199
|
+
operation counts the unit that actually costs, and the two resize-family gates use measured, lower ceilings (the flat
|
|
200
|
+
`maxOperationCells` would let multi-second freezes through):
|
|
201
|
+
|
|
202
|
+
| Operation | `cellCount` counts | Ceiling |
|
|
203
|
+
| :--- | :--- | :--- |
|
|
204
|
+
| `copy` / `cut` / `delete` / `paste` | Cells of the operated range(s) | `maxOperationCells` |
|
|
205
|
+
| `fill` | Region cells (box − source) **+ charged source reads** — full m×n source block for a diagonal box; orthogonal span × min(source length, band length) for a pure band (v0.2-identical). An upper bound on distinct reads; a sum, not a range size | `maxOperationCells` |
|
|
206
|
+
| `resize` (drag fan-out, columns) | Target tracks across all full-span selected ranges | `maxOperationCells` |
|
|
207
|
+
| `resize` (drag fan-out, rows) | Target tracks, same as columns | **`maxOperationCells / 10`** — per-row height updates carry an unbatchable per-call cost (measured) |
|
|
208
|
+
| `resize` (double-click autofit) | **Measured cells** = target columns × visible rows (the unit that costs; a track-count gate would let 16k columns × 40 visible rows through) | **`maxOperationCells / 4`** — per-cell text measurement cost (measured) |
|
|
209
|
+
| `resize` (`hideRows` / `unhideRows` commands) | Selected row span (counted before materialising, overlapping ranges counted twice) | **`maxOperationCells / 10`** (same as row fan-out) |
|
|
210
|
+
| `resize` (`hideColumns` / `unhideColumns` commands, pasted column widths) | Selected column span / pasted column tracks (the paste rectangle's width — the payload placed once on a 1 × 1 target, tiled over an integer-multiple target) | `maxOperationCells` |
|
|
211
|
+
| `resize` (pasted row heights) | Pasted row tracks (the paste rectangle's height, counted the same way — a whole-row copy of R rows pasted onto a 1 × 1 target counts R) | **`maxOperationCells / 10`** (row axis — the same divisor as row fan-out and `hideRows`; a refusal leaves the pasted cells in place, because sizes are applied after the cell commit) |
|
|
212
|
+
| `format` / `calc` / `annotate` | Cells of the selected range(s) | `maxOperationCells` |
|
|
213
|
+
|
|
214
|
+
`reason: "bounds"` has three sources: a cut-move clipped at the grid edge, a cut-move whose armed source range has fallen off
|
|
215
|
+
the source adapter's **current extents** (rows / columns deleted after `Ctrl+X` — see "Cut matching rule"), and a row hide
|
|
216
|
+
(drag-to-zero, fan-out, the `hideRows` command or pasted row heights of 0) that would push the **axis-wide hidden-row total
|
|
217
|
+
past 1,000** — for that one, `cellCount` reports the would-be hidden total.
|
|
218
|
+
|
|
219
|
+
## Copy / cut marquee (marching ants)
|
|
220
|
+
|
|
221
|
+
Both `Ctrl+C` and `Ctrl+X` arm an animated marching-ants marquee drawn **3px outside** the source range's border, so it
|
|
222
|
+
never hides under the selection fill or the active-cell ring (visually identical for both kinds, Excel-style; the node
|
|
223
|
+
carries `data-aqsp-clip-kind="copy" | "cut"` for styling/test hooks). A **copy** marquee survives
|
|
224
|
+
paste — repeated pasting keeps the ants, exactly like Excel (including pastes of foreign clipboard content: the
|
|
225
|
+
browser exposes no clipboard-change event, so unlike desktop Excel the ants cannot auto-dismiss when another app
|
|
226
|
+
replaces the clipboard — a purely visual trait, since a copy clip can never trigger the move path) — and is dismissed by Escape, starting an edit, any other
|
|
227
|
+
successful `applyEdits` **except a format or annotation commit** (Ctrl+B, a note or link write keep the ants, like Excel), undo/redo, a sheet switch, or a `dataVersion` bump. A **cut** marquee is consumed by its
|
|
228
|
+
paste (the move) and additionally dismissed by the same triggers — with one exception: a sheet switch keeps the cut record as a
|
|
229
|
+
*foreign* clip (marquee hidden, `onClipChange` reports `foreign: true`) so it can complete as a cross-sheet move; Escape discards
|
|
230
|
+
a foreign cut too. Paste Special of an armed cut (values only, transpose, an operation, skip blanks) is a **copy-semantics** paste
|
|
231
|
+
that keeps the cut armed. The ants stop animating under
|
|
232
|
+
`prefers-reduced-motion: reduce` (the dashes stay visible) and degrade to a dashed `Highlight` border on the same
|
|
233
|
+
edge-clamped ::after in forced-colors mode. Non-editing cells use the platform `default` (arrow) cursor; **only the cell being edited shows the `text` (caret) cursor** over its content. The Excel-style `cell` cross was dropped so the double-click no longer flashes the default arrow *between* two cross states (the reported 十字→矢印→十字) — the cursor value now changes at most once, on edit entry (arrow → caret).
|
|
234
|
+
|
|
235
|
+
## Cut matching rule
|
|
236
|
+
|
|
237
|
+
Every copy or cut stamps its payload with a fresh 128-bit **clip id** (`crypto.getRandomValues`) that travels in all three
|
|
238
|
+
flavours the package writes — `text/plain` never carries it, so the match never depends on the text. `Ctrl+X` arms a cut record
|
|
239
|
+
(`{ kind: "cut", range, clipId, sourceAdapter, payload }`). A later paste becomes a **move** (clear source + write destination) only
|
|
240
|
+
when ALL of these hold:
|
|
241
|
+
|
|
242
|
+
1. the clipboard's detected source is the package's own flavour (`application/x-aqsp-clip` on the paste event, or the
|
|
243
|
+
`data-aqsp-clip` attribute of the `text/html` table on either channel) **and** its header `clipId` equals the armed one —
|
|
244
|
+
the identity check reads only the first 512 bytes, never the body; a `text/plain`-only paste after a cut (another app
|
|
245
|
+
replaced the clipboard, or a host that strips HTML) is a **plain paste**, not a move;
|
|
246
|
+
2. the paste is a *plain* paste: `mode: "all"`, no `operation`, no `transpose`, no `skipBlanks` — every Paste Special of a
|
|
247
|
+
cut is a copy-semantics paste that keeps the cut armed (Excel disables Paste Special after a cut; here it simply copies).
|
|
248
|
+
|
|
249
|
+
A move never tiles (a 1×1 target expands once from the active cell) and is rejected whole with `reason: "bounds"` when it would
|
|
250
|
+
clip at the grid edge (the cut stays armed). The same `"bounds"` refusal fires when the armed source range no longer fits the
|
|
251
|
+
source adapter's **current extents** (rows or columns deleted after `Ctrl+X`, with or without a sheet switch — the deferred
|
|
252
|
+
re-read and the source clears must never touch outside the grid; the cut stays armed, so restoring the axis lets the same
|
|
253
|
+
record move). The cut record is a **reference to the range**, as in Excel: the moved cells are re-read
|
|
254
|
+
from the source adapter at paste time (the payload captured at `Ctrl+X` keeps only the clip id and the dimensions), so a format,
|
|
255
|
+
note, comment, hyperlink or validation committed after `Ctrl+X` (those commits keep the cut armed) travels with the move instead of
|
|
256
|
+
being silently destroyed on both sides; a copy remains the snapshot taken at `Ctrl+C` (Sheets semantics). The source clear writes `text: ""` plus `null` for every aspect the **source**
|
|
257
|
+
adapter declares (format / note / comments / hyperlink / validation). **Same sheet**: clears then writes in ONE transaction
|
|
258
|
+
(`sameSheet: true`, overlap resolves clear → write). **Another sheet** (the adapter object changed since the cut): the target
|
|
259
|
+
sheet is written first with `{ sameSheet: false, conditionalFormats: <translated rules> }`, then the source adapter receives the
|
|
260
|
+
clears under the **same transaction id** (`conditionalFormats: []` — a cut never moves or removes rules from the source sheet); a
|
|
261
|
+
throw from the source side is reported through `onEditRejected(error, { source: "paste" })` while the target write stands.
|
|
262
|
+
|
|
263
|
+
The cut is disarmed (back to nothing) by Escape, starting an edit, a subsequent successful copy or cut, any successful
|
|
264
|
+
`applyEdits` other than a format / annotation commit, an undo/redo relay, or a `dataVersion` bump; a sheet switch keeps it as a
|
|
265
|
+
foreign clip (see the marquee section). Changing the selection does **not** disarm it (clicking the paste target is itself a
|
|
266
|
+
selection change), and a rejected paste (limit / bounds / adapter throw) leaves it armed for a retry. The move consumes the record;
|
|
267
|
+
a second `Ctrl+V` of the same clipboard content is a plain paste (see deviations).
|
|
268
|
+
|
|
269
|
+
## Resize, hide/unhide, and autofit (pointer UI)
|
|
270
|
+
|
|
271
|
+
Each header boundary carries a 6px grab strip **inside** the boundary (Excel's boundary-straddling zone is not replicated —
|
|
272
|
+
the first pixels of the next header still select the column). Dragging shows a guideline; cell sizes change only at release,
|
|
273
|
+
and releasing at the initial size is a no-op. Dragging a boundary to zero **hides** the track; a boundary that hides a run
|
|
274
|
+
shows a 3px double-line **unhide marker** (a hidden run starting at column A / row 1 gets the marker on the first visible
|
|
275
|
+
header's leading edge): dragging the marker re-expands the nearest hidden track, double-clicking it restores the default
|
|
276
|
+
size. Degenerate (< 12px) tracks keep a 3px affordance overhanging the neighbor, so a 1px-clamped track is always
|
|
277
|
+
recoverable. The last visible track of an axis can never be zeroed (it clamps to 1px) — the grid cannot be made fully
|
|
278
|
+
invisible, because no menu-based recovery path exists here.
|
|
279
|
+
|
|
280
|
+
When the pressed track belongs to a **full row/column selection** (header selections, Ctrl+A), the resize **fans out** to
|
|
281
|
+
every selected track — hidden tracks included, which makes fan-out an implicit unhide (Excel-compatible). Fan-outs and
|
|
282
|
+
hides are gated (see the limits table above; row hides are additionally capped at 1,000 hidden rows per axis).
|
|
283
|
+
|
|
284
|
+
**Double-click autofit** (columns): each selected column is measured individually over the cells of the **current visible
|
|
285
|
+
window** (not all data) and set to the widest text + padding; columns whose visible cells are all empty are left untouched.
|
|
286
|
+
The measured text is the **formatted display string** (what the screen actually shows — v0.7): a currency column autofits
|
|
287
|
+
to `"¥203,428"`, not to the raw `"203428"`, which used to left-clip the most significant digits of right-aligned cells.
|
|
288
|
+
Row autofit restores the default row height (cells render single-line clipped). Autofit is disabled entirely when
|
|
289
|
+
`renderCell` is set — custom cell DOM cannot be measured.
|
|
290
|
+
|
|
291
|
+
> **Autofit measures with the header's font as its base.** Text measurement composes its font from the **header band's**
|
|
292
|
+
> computed style (style / weight / size / family); since v0.8 the cell's **painted** format (bold / italic from the base format
|
|
293
|
+
> and width-relevant conditional formats, `fontSize` in pt × 4/3, `fontFamily`) overrides those parts per cell, bucketed so
|
|
294
|
+
> `ctx.font` is assigned once per distinct font. If your CSS gives body cells a different base font than the headers, autofit
|
|
295
|
+
> will mismeasure — keep cell and header base fonts identical (the shipped stylesheets do).
|
|
296
|
+
|
|
297
|
+
`enableResizeUI={false}` removes the strips **and the unhide markers** — a consumer with hidden tracks then has no
|
|
298
|
+
in-package restore affordance (only the imperative API). The imperative `resizeRow` / `resizeColumn` calls bypass every UI
|
|
299
|
+
gate (no 1px clamp, no hidden-row cap, no fan-out limit): mass-hiding rows through the API is your responsibility and falls
|
|
300
|
+
outside the rendering guarantee.
|
|
301
|
+
|
|
302
|
+
## Fill handle (autofill)
|
|
303
|
+
|
|
304
|
+
A single-range selection shows a crosshair handle on its bottom-right corner (hidden while editing, during any other drag,
|
|
305
|
+
and for multi-range selections). Dragging it extends toward the hover cell as a **bounding box** of source ∪ hover —
|
|
306
|
+
single-axis drags give the classic band, and **diagonal drags fill the L-shaped region** (right band + bottom band +
|
|
307
|
+
corner) in one gesture (a deliberate super-set of Excel, which is axis-constrained; cell-boundary quantization absorbs
|
|
308
|
+
in-track jitter). The dashed preview shows the box; releasing applies a **cyclic copy fill** — the source pattern tiles
|
|
309
|
+
with 2D source-start modular anchoring, and upward/leftward drags continue the cycle in reverse — as **one** `applyEdits`
|
|
310
|
+
transaction. On success the selection becomes the box (anchor and active stay put).
|
|
311
|
+
|
|
312
|
+
- Movements under 4px never write (Excel's drag threshold) — a plain click on the handle changes nothing.
|
|
313
|
+
- The confirmed box is the **last previewed** box; hovering back inside the source collapses the box to nothing, so
|
|
314
|
+
releasing there changes nothing (Excel *clears* the re-covered cells — see deviations; planned).
|
|
315
|
+
- Copy fill only: no series/smart fill, and double-clicking the handle does **not** fill down to adjacent data.
|
|
316
|
+
- The drag carries exactly what `fillDown` / `fillRight` carry (v0.8): with `getCellFormula` declared a formula source is
|
|
317
|
+
shifted per target cell (`shiftFormula` — relative references move, `$` anchors stay); the declared aspects — canonical
|
|
318
|
+
`format`, `validation`, `hyperlink`, `note` — are copied from the source (a source without one clears the target's);
|
|
319
|
+
comments never travel (see deviations). A v0.7 adapter receives plain `{ row, col, text }` edits exactly as before.
|
|
320
|
+
- The fill is size-estimated before any cell is read (see the limits table) — a rejection leaves data and selection
|
|
321
|
+
untouched, as does a rejected `applyEdits`.
|
|
322
|
+
|
|
323
|
+
`enableFillHandle={false}` removes the handle and inerts the machine — recommended for read-only adapters, where every fill
|
|
324
|
+
attempt would otherwise end in `onEditRejected`.
|
|
325
|
+
|
|
326
|
+
## Accessibility model and trade-offs
|
|
327
|
+
|
|
328
|
+
The grid renders cells as a **non-focusable virtualized surface** with a hidden 1px textarea ("sink") owning focus and
|
|
329
|
+
keyboard input — this is what makes IME composition, clipboard events, and 16M-cell virtualization coexist. The trade-offs:
|
|
330
|
+
|
|
331
|
+
- Screen readers do not walk cells as a native table/grid. Instead, cell and selection changes can be **announced** through a
|
|
332
|
+
debounced `aria-live="polite"` region — **strictly opt-in**: the package ships no built-in strings, and the live region is
|
|
333
|
+
rendered only when you provide `formatCellAnnouncement` and/or `formatSelectionAnnouncement`. Omit both and the grid is
|
|
334
|
+
silent to assistive technology beyond its labels. The cell-announcement context carries both `text` (the raw canonical
|
|
335
|
+
`getCellText`) and `displayText` (the formatted string a sighted user sees — identical to `text` on unformatted cells),
|
|
336
|
+
so a formatter can announce `"87.0%"` rather than `"0.87"`.
|
|
337
|
+
- `labels.sinkAriaLabel` labels the input surface (its default explains the Escape hand-off in English); `labels.rootAriaLabel`
|
|
338
|
+
labels the grid container (default `"Spreadsheet"`). Both live in the `labels` prop (v0.8 — the former `sinkAriaLabel` /
|
|
339
|
+
`rootAriaLabel` props were folded into it, together with the 21 popover / indicator strings; `DEFAULT_LABELS` is exported and
|
|
340
|
+
an omitted or `undefined` key keeps its default). **Localize both together**, and a replacement `sinkAriaLabel` **must keep the
|
|
341
|
+
Escape-exit notice** — the word `Escape` must appear, otherwise the grid throws a `RangeError` while rendering: Escape moves
|
|
342
|
+
focus from the sink to the root, and without the notice (or with an unlabeled landing point) screen-reader users are stranded
|
|
343
|
+
mid-grid. Popovers are keyboard-complete (`role="dialog"` editors with an autofocused field, `role="tooltip"` note tip wired to
|
|
344
|
+
the sink's `aria-describedby` while its cell is active — a while-invariant, re-evaluated as arrow keys move the active cell in
|
|
345
|
+
both directions —, `role="combobox"` + `listbox` list picker, `role="status"` message) and
|
|
346
|
+
return focus to the sink on close; `formatCommandAnnouncement` (opt-in) announces command results with the toggle state.
|
|
347
|
+
- Keyboard reachability is complete for the keymap below: every listed operation works without a pointer, and `Escape` from
|
|
348
|
+
the grid root returns focus to the page tab order (WCAG 2.1.2 — no keyboard trap). The grid never takes focus on mount
|
|
349
|
+
(the hidden input is armed focus-neutrally — WCAG 3.2.1 / 2.4.3): keyboard entry is the page tab order (Tab / Shift+Tab)
|
|
350
|
+
or a click, and re-entry always lands on the hidden input. The pointer-only affordances have non-pointer equivalents at
|
|
351
|
+
other layers: resizing via the imperative `resizeRow` / `resizeColumn`, filling via your own `applyEdits`-based UI.
|
|
352
|
+
- The hidden input's `inputmode` is a **virtual-keyboard invitation**, not a mirror of the editing session — and not an idle
|
|
353
|
+
/ editing predicate. It starts as `inputmode="none"`, and for keyboard and IME users it stays `"none"` forever (F2, a
|
|
354
|
+
printable key, or a composition opens the editor without touching the attribute): a touch / pen tap does not summon a
|
|
355
|
+
virtual keyboard (touch / pen is scroll-only — a tap never selects a cell or opens the editor, a long-press never fires
|
|
356
|
+
`onCellContextMenu`, and every press gate is mouse-only). Only an editor opened by pointer (double-click — the one way a
|
|
357
|
+
keyboard-less device can open one) raises the invitation to `inputmode="text"`, so a pointer-driven session on a
|
|
358
|
+
keyboard-less device whose pointer reports as `mouse` (iPad + trackpad, a tablet mode with a mouse) can get the on-screen
|
|
359
|
+
keyboard. The invitation then outlives the session: it stays `"text"` through Enter / Tab commits and Escape and through
|
|
360
|
+
the idle state after them ("type → Enter → type" keeps the keyboard), and is withdrawn only when focus really leaves the
|
|
361
|
+
grid or an idle (no session open) touch / pen press arrives (scroll intent — a tap that bounces focus back to the hidden
|
|
362
|
+
input must not summon the keyboard). A touch / pen press during an open session — caret placement or scrolling — leaves it
|
|
363
|
+
alone, so a user typing on the on-screen keyboard is not robbed of it; the withdrawal comes with the next idle touch / pen
|
|
364
|
+
press after the session ends. Because the imperative `startEdit` deliberately opens the editor **without** raising the invitation (it never
|
|
365
|
+
touches `inputmode`), the mouse double-click is the only invitation channel: a consumer-rendered "Edit" control (a toolbar
|
|
366
|
+
button calling `handle.startEdit`) opens the editor but cannot summon the on-screen keyboard — a documented limitation, not
|
|
367
|
+
a defect. The attribute therefore changes only on pointer and focus events (the double-click, whose press
|
|
368
|
+
gate has already committed any composition; an idle touch / pen press; a real grid exit, whose blur finishes a composition
|
|
369
|
+
before the focusout) and never inside an IME composition that has a session (the only session-less composition is one
|
|
370
|
+
started while a drag is in flight — `startEdit` bails on `isDragging` — and the idle sink's self-heal re-arms on every
|
|
371
|
+
`input` while no session is open, discarding it, so nothing a user could keep is affected): Chromium on Windows, Linux,
|
|
372
|
+
ChromeOS and Android forwards the change to the platform IME as a text-input-state change (macOS ignores mode-only
|
|
373
|
+
changes), and on Windows `InputMethodWinTSF::OnTextInputTypeChanged` then cancels the in-flight composition
|
|
374
|
+
(`TSFBridge::CancelComposition()` once its three early-return guards pass — a TSF bridge exists,
|
|
375
|
+
the client is the focused text-input client, the window is focused, all true in the reported situation — whether or not a
|
|
376
|
+
composition is in flight or only `inputmode` changed), which is how a session-driven attribute used to swallow a
|
|
377
|
+
single-character IME entry from an idle cell. Whether the keyboard actually appears when the attribute changes on the
|
|
378
|
+
already-focused editor depends on the engine: Chromium on Windows, Linux, ChromeOS and Android forwards the change to the
|
|
379
|
+
platform IME (macOS ignores mode-only changes), but the on-screen keyboard is shown only for focus-driven (or
|
|
380
|
+
touch-tap-driven) updates — the renderer sets `show_ime_if_needed` only for those, never for a bare attribute mutation —
|
|
381
|
+
so a bare attribute change cannot summon it (Chrome on Android with a mouse, or a Windows tablet mode with a mouse, does
|
|
382
|
+
not show it — a known limitation; a blur-and-refocus workaround would ride the focus-out discard path, so it is not used);
|
|
383
|
+
WebKit re-evaluates `inputmode` on the focused element and can show it (iPad + trackpad gets the keyboard). Hardware
|
|
384
|
+
keyboards and desktop IME are unaffected either way.
|
|
385
|
+
|
|
386
|
+
## Context-menu constraint (right-click)
|
|
387
|
+
|
|
388
|
+
Right-clicking a cell moves the selection (Excel-style) and calls `onCellContextMenu` with the cell and the native event —
|
|
389
|
+
**the package renders no menu**; suppress the native menu and show your own, or ignore the callback to keep the browser menu.
|
|
390
|
+
`Shift+F10` / the ContextMenu key deliver the same callback with a `keyboard` origin and the active cell's viewport
|
|
391
|
+
rectangle. Right-clicking column/row headers is intentionally native (no column-select-plus-menu — see deviations).
|
|
392
|
+
|
|
393
|
+
Right-clicks never strand keyboard focus. On every press surface (cells, column / row headers, resize strips, the corner, the
|
|
394
|
+
fill handle, the scrollbar bands, and — as the last gate on the zoom wrapper — any unowned dead space such as the strip below
|
|
395
|
+
the row-header band) a non-primary-button `pointerdown` (secondary, middle, back / forward) is ignored by the selection
|
|
396
|
+
machinery, but its default action — the mousedown focus move from the hidden input onto the grid container — is cancelled
|
|
397
|
+
(`preventDefault()` only: no `stopPropagation()`, no selection change); for the secondary button the `contextmenu` event plus
|
|
398
|
+
the native menu still fire. Every mouse cell-area right-click that reaches the context-menu handler (i.e. not swallowed by the
|
|
399
|
+
drag freeze) additionally recovers focus to the hidden input — before the hit test, whether or not it replaces the selection
|
|
400
|
+
(a right-click inside the current selection leaves it unchanged) and whether or not the editing commit it triggers succeeds
|
|
401
|
+
(a rejected commit keeps the session) — so Arrow keys, typing, and Escape keep working right after the click, also when focus
|
|
402
|
+
was outside the grid before it; a consumer menu that focuses its own element afterwards still wins.
|
|
403
|
+
|
|
404
|
+
Chorded buttons are covered too: a second button pressed or released while the primary is held arrives as a `pointermove`
|
|
405
|
+
(never as `pointerdown` / `pointerup`, so no press gate runs) and is cancelled the same way by a root-level pointer watcher;
|
|
406
|
+
releasing the primary first while a chord is held still confirms the drag instead of rolling it back — cell, header, and
|
|
407
|
+
resize at that position, fill at its last painted preview band (the same box its `pointerup` confirms). The press gates are
|
|
408
|
+
backed by a focus invariant: the only way focus leaves the hidden input for the grid container is the Escape hand-off, so
|
|
409
|
+
the container returns focus to the hidden input only when focus arrives on it straight from the hidden input without that
|
|
410
|
+
hand-off — a touch / pen tap (the press gates, including the zoom-wrapper dead-space gate, are mouse-only) — without re-arming,
|
|
411
|
+
so an editing session keeps its value. Keyboard traversal is left alone: focus landing on the container from another in-grid tab
|
|
412
|
+
stop (a scrollbar arrow button, a `renderCell` focusable) rests on the container, so Shift+Tab can leave the grid backwards.
|
|
413
|
+
A middle click never pastes into the grid: the middle-button release is cancelled (also when it lands outside the grid, and
|
|
414
|
+
also when the middle button was chorded onto a held primary; a touch tap while the middle button is held does not disarm
|
|
415
|
+
it) so Linux's primary-selection paste cannot reach the hidden input; the cell editor itself keeps native middle-click paste.
|
|
416
|
+
The hidden input carries `inputmode="none"` from mount and throughout every keyboard / IME session (the attribute is a
|
|
417
|
+
virtual-keyboard invitation, not an idle / editing predicate), so a touch / pen tap does not summon a virtual keyboard (touch
|
|
418
|
+
/ pen is scroll-only — a tap never selects a cell or opens the editor); only a pointer-opened editor (mouse double-click)
|
|
419
|
+
raises the invitation to `inputmode="text"`, which then outlives the session — it stays `"text"` through commits, Escape and
|
|
420
|
+
the idle state after them — until focus really leaves the grid or an idle (no session open) touch / pen press withdraws it; a
|
|
421
|
+
touch / pen press during a session leaves it alone (see Accessibility).
|
|
422
|
+
Touch / pen long-press is not affected (these gates are mouse-only) — and it never reaches `onCellContextMenu` either: the
|
|
423
|
+
context-menu handler is mouse-only as well, so touch stays a scroll gesture from press to release.
|
|
424
|
+
|
|
425
|
+
## Keymap (contract table)
|
|
426
|
+
|
|
427
|
+
Modifiers match **strictly** — combinations not listed fall through to the browser untouched, with three deliberate
|
|
428
|
+
exceptions: `Ctrl+Shift+PageUp` / `Ctrl+Shift+PageDown` (the sheet-switch class — there are no sheets here) are consumed
|
|
429
|
+
as a no-op while idle (mid-edit they pass through natively, per the table columns), during an
|
|
430
|
+
in-flight drag **all** non-Escape keys are consumed (state freeze), and Escape itself aborts an in-flight drag with any
|
|
431
|
+
modifier held (the abort branch runs before the strict-modifier passthrough).
|
|
432
|
+
|
|
433
|
+
**Package zoom**: Alt+Wheel over the grid and Ctrl+Alt+`=` / `+` / `-` / `0` change the package zoom (Ctrl+Wheel
|
|
434
|
+
stays with the browser). All four keys match the PRODUCED character (`event.key`) regardless of Shift, so layouts that
|
|
435
|
+
need Shift to type them work as-is — JIS `=` is Shift+`-`, US `+` is Shift+`=`, AZERTY `0` is Shift+`à`; mismatching
|
|
436
|
+
shifted characters (US `_` / `)`) pass through. Chords carrying the `AltGraph` modifier state are never claimed —
|
|
437
|
+
a defense-in-depth: evergreen browsers (Chromium 68+ / Firefox 63+) deliver real AltGr characters WITHOUT ctrl+alt,
|
|
438
|
+
so typing e.g. Latvian AltGr+F `=` never reaches this handler at all; the guard shields legacy AltGr-emulation
|
|
439
|
+
shapes (old engines, synthetic events, non-conforming embedders) only. The remaining consequence is
|
|
440
|
+
layout-dependent bindings (e.g. German Shift+0 produces `=` and zooms in) — a host that needs a layout-independent
|
|
441
|
+
chord should claim it by `event.code` in a capture listener ahead of the grid (`defaultPrevented` events are
|
|
442
|
+
respected and ignored). Alt+Wheel claims ONLY the bare Alt chord — Shift/Ctrl/Meta-carrying Alt+Wheel passes through untouched (e.g. the Shift+wheel horizontal-scroll idiom stays with the browser). Passed-through chords that produce no character never open an edit session (the ctrl+alt printable-shape rule defers the session until a character actually arrives). Requests are ignored mid-drag; in controlled
|
|
443
|
+
mode these fire `onZoomChange` only. The zoom keys are
|
|
444
|
+
claimed in the capture phase — defense in depth: since v0.3.3 the deferred printable open alone guarantees no
|
|
445
|
+
accidental edit session, so the capture claim is a redundant belt, kept deliberately. Other Ctrl+Alt combinations pass
|
|
446
|
+
through untouched **except the command rows of the table below** (`Ctrl+Alt+M` = `insertComment`, `Ctrl+Alt+Digit9` =
|
|
447
|
+
`hideRows`, `Ctrl+Alt+V` = `pasteSpecial`, and whatever `shortcuts` binds); command rows are never claimed while the
|
|
448
|
+
`AltGraph` modifier state is set, and on layouts whose produced character for a `Ctrl+Alt(+Shift)` chord is `=` / `+` / `-` / `0`
|
|
449
|
+
the zoom keys shadow that chord (the zoom token space `Ctrl+Alt` and `Ctrl+Alt+Shift` × `Equal` / `Minus` / `Digit0` /
|
|
450
|
+
`NumpadAdd` / `NumpadSubtract` / `Numpad0` is reserved and cannot be bound). Events an outer (host) capture listener has already
|
|
451
|
+
`preventDefault`ed are respected and ignored. In controlled mode, gestures step from the last *requested* value: a parent that rejects a
|
|
452
|
+
request without re-rendering leaves the next detent continuing from the rejected value until any re-render re-syncs it —
|
|
453
|
+
enforce a tighter range by clamping in `onZoomChange` and re-rendering with the clamped value. Wheel/key steps land on the nearest 0.1 detent in the travel direction
|
|
454
|
+
(from the 0.25 bound the first step up is 0.3); Alt+Wheel with no vertical component (tilt wheel / horizontal pan) is
|
|
455
|
+
passed through to the browser.
|
|
456
|
+
|
|
457
|
+
Navigation is **visible-track aware**: arrows and Shift-arrows step to the next *visible* row/column (a hidden run is
|
|
458
|
+
jumped in one keystroke; if none exists in that direction the active cell stays), Home/End land on the first/last visible
|
|
459
|
+
track, and paging moves by exact viewport-height pixel geometry, so page landings are structurally visible.
|
|
460
|
+
|
|
461
|
+
| Key | Idle | Editing (replace) | Editing (edit) | Command |
|
|
462
|
+
| :--- | :--- | :--- | :--- | :--- |
|
|
463
|
+
| Arrow | Move active one visible step (reset) | Commit + move — **while pointing**: move the reference cursor and insert / replace the token | Native caret — **while pointing**: reference cursor | — |
|
|
464
|
+
| Shift+Arrow | Extend range (visible step) | Commit + start extension — while pointing: extend the token to a range | Native — while pointing: range token | — |
|
|
465
|
+
| Ctrl+Arrow | Jump to data edge (with `findDataEdge`) / grid edge (without) | Native word caret — while pointing: reference cursor by data edge | Native — while pointing: data-edge cursor | — |
|
|
466
|
+
| Ctrl+Shift+Arrow | Extend to data edge (with `findDataEdge`) / to visible grid edge (without) | Consumed (no-op) | Consumed (no-op) | — |
|
|
467
|
+
| Tab / Shift+Tab | Cycle across selected ranges (identity resets to a move: single 1×1, or a full circle over duplicate identical ranges) | Commit + cycle | Commit + cycle | — |
|
|
468
|
+
| Enter / Shift+Enter | Cycle across selected ranges (identity resets to a move: single 1×1, or a full circle over duplicate identical ranges) | Commit + cycle | Commit + cycle | — |
|
|
469
|
+
| Alt+Enter | Open the active cell's hyperlink: with `onHyperlinkOpen` declared the row exists only while the active cell has a link (`onHyperlinkOpen(url, active)`; a cell without a link passes through — nothing is consumed), without it the link popover opens (no Open button — the Ctrl+K editor, also on a cell without a link) | Insert newline at caret | Insert newline at caret | `openHyperlink` (idle column only — the editing newline row is a different column) |
|
|
470
|
+
| Home / End (+Ctrl) | First/last visible column / visible grid corner | Native | Native | — |
|
|
471
|
+
| Shift+Home / Shift+End | Extend to the first / last visible column | Native | Native | — |
|
|
472
|
+
| Ctrl+Shift+Home / End | Extend to the first / last visible grid corner | Native | Native | — |
|
|
473
|
+
| PageUp / PageDown | Page move (one viewport height, exact) | Commit + page move | Commit + page move | — |
|
|
474
|
+
| Shift+PageUp / PageDown | Extend by one viewport height | Native | Native | — |
|
|
475
|
+
| Ctrl+Space / Shift+Space | Select whole columns / rows of **every** selected range (anchor/active stay) | Native | Native | — |
|
|
476
|
+
| F2 | Start editing (edit) | Toggle the pointing arm — a session opens disarmed (Excel Edit mode: the first Arrow moves the caret), typing a trigger arms it, F2 is Excel's Edit ⇄ Enter (formula session with `getCellFormula`; otherwise native) | Toggle the pointing arm | — |
|
|
477
|
+
| F4 | — | Cycle the `$` anchor of the reference under the caret (`A1` → `$A$1` → `A$1` → `$A1`) | Same | — |
|
|
478
|
+
| Delete / Backspace | Clear selection (one transaction: `text: ""`, plus `hyperlink: null` when `getCellHyperlink` is declared) | Native | Native | — |
|
|
479
|
+
| Escape | Abort drag > hide the note tip > close the rejected validation message > disarm clip (copy or cut, foreign too) > focus root | Cancel (discard value) — mid reference-drag: abort the drag only (token restored, session kept) | Same | — |
|
|
480
|
+
| Ctrl+A | Select all (active stays) | Native | Native | — |
|
|
481
|
+
| Ctrl+C / X / V | Clipboard (native event path) | Native textarea | Native textarea | — |
|
|
482
|
+
| Ctrl+Shift+V | Values-only paste (arms `{ mode: "values" }` for the next native paste event; no `preventDefault`; layout-independent — matched by the VK rule like every letter row, so RU `Ctrl+Shift+М` / EL `Ctrl+Shift+Ω` on `KeyV` arm too) | Native | Native | — (fixed chord, not a command) |
|
|
483
|
+
| Ctrl+Alt+V | Paste Special: `onPasteSpecialRequest({ kind: "keyboard", cellRect })` when declared, else a formats-only paste through the menu path | Native | Native | `pasteSpecial` (**non-rebindable**) |
|
|
484
|
+
| Ctrl+Z / Y / Shift+Z | Relay to undo/redo callbacks | Native textarea undo | Native textarea undo | — |
|
|
485
|
+
| Shift+F10 / ContextMenu | Context-menu callback | — | — | — |
|
|
486
|
+
| Ctrl+B / Ctrl+I / Ctrl+U / Alt+Shift+Digit5 | Toggle on the selection (the active cell's state decides: on → clear, off → set) | Toggle on the session cell (editor repainted) | Same | `bold` / `italic` / `underline` / `strikethrough` |
|
|
487
|
+
| Ctrl+Backslash / Ctrl+IntlYen / Ctrl+IntlRo | Clear every format key of the selection | Native | Native | `clearFormatting` |
|
|
488
|
+
| Ctrl+Shift+L / Ctrl+Shift+E / Ctrl+Shift+R | Align left / center / right | Session cell | Same | `alignLeft` / `alignCenter` / `alignRight` |
|
|
489
|
+
| Ctrl+Shift+Digit1 … Ctrl+Shift+Digit6 | Number (2 decimals, grouping) / time (`HH:mm:ss`) / date / currency (needs the `formats` prop) / percent / scientific value format | Session cell | Same | `formatNumber` / `formatTime` / `formatDate` / `formatCurrency` / `formatPercent` / `formatScientific` |
|
|
490
|
+
| Alt+Shift+Digit7 / Alt+Shift+Digit6 | Outer borders of each range (inner borders kept) / clear borders | Native | Native | `bordersOuter` / `bordersClear` |
|
|
491
|
+
| Alt+Shift+Digit1 … Alt+Shift+Digit4 | Top / right / bottom / left edge of each range (thin, `#000000`; other cells' borders kept) | Native | Native | `borderTop` / `borderRight` / `borderBottom` / `borderLeft` |
|
|
492
|
+
| Ctrl+K | Link popover on the active cell | Native | Native | `insertLink` |
|
|
493
|
+
| Shift+F2 | Note popover on the active cell | Native | Native | `insertNote` |
|
|
494
|
+
| Ctrl+Alt+M | Comment thread popover on the active cell | Native | Native | `insertComment` |
|
|
495
|
+
| Alt+ArrowDown | Validation list picker (cells with a `list` rule) | Native | Native | `openValidationList` |
|
|
496
|
+
| Ctrl+D / Ctrl+R | Fill down / right (first row / column of each range, or the neighbour above / left for a single row / column; formulas shift) | Native | Native | `fillDown` / `fillRight` |
|
|
497
|
+
| 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` |
|
|
498
|
+
| 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` |
|
|
499
|
+
| 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` |
|
|
500
|
+
| *(unbound by default)* | Hide the selected columns — bind it yourself, e.g. `shortcuts={{ hideColumns: ["Ctrl+Alt+Shift+Digit9"] }}` | — | — | `hideColumns` |
|
|
501
|
+
| *(unbound by default)* | General (no value format) / all four borders | — | — | `formatGeneral` / `bordersAll` |
|
|
502
|
+
| *(unbound by default)* | Paste Special modes through the menu path (`handle.paste`) | — | — | `pasteFormats` / `pasteFormulas` / `pasteAdd` / `pasteSubtract` / `pasteMultiply` / `pasteDivide` |
|
|
503
|
+
| *(unbound by default)* | Self-calc over the selection (exact decimal shift, formulas wrapped `=(…)*100`; non-numeric cells untouched) | — | — | `multiplyBy100` / `multiplyBy1000` / `divideBy100` / `divideBy1000` / `signReversal` |
|
|
504
|
+
| *(unbound by default)* | Fill colour toggles (pastel presets `#FFFF99` / `#CCFFFF` / `#CCFFCC` / `#FFCCCC` — legible behind the default text colour, the reference extension's values — / the five `colors.fill` slots) | — | — | `fillColorYellow` / `fillColorBlue` / `fillColorGreen` / `fillColorRed` / `fillColorCustom1` / `fillColorCustom2` / `fillColorCustom3` / `fillColorCustom4` / `fillColorCustom5` |
|
|
505
|
+
| *(unbound by default)* | Font colour toggles (pure presets `#FFFF00` / `#0000FF` / `#00FF00` / `#FF0000` — a separate table from the fills — / the five `colors.font` slots) | — | — | `fontColorYellow` / `fontColorBlue` / `fontColorGreen` / `fontColorRed` / `fontColorCustom1` / `fontColorCustom2` / `fontColorCustom3` / `fontColorCustom4` / `fontColorCustom5` |
|
|
506
|
+
| *(unbound by default)* | Highlight band toggles (`highlight` state) | — | — | `toggleRowHighlight` / `toggleColumnHighlight` / `toggleBothHighlight` / `toggleIntersectionHighlight` |
|
|
507
|
+
| Printable / IME start | Start editing (replace) — macOS `Option`+char does not (see deviations) | (typing) | (typing) | — |
|
|
508
|
+
|
|
509
|
+
### Command shortcuts (grammar, rebinding, reserved chords)
|
|
510
|
+
|
|
511
|
+
Rows with a **Command** are rebindable through the `shortcuts` prop (except `pasteSpecial`) and reachable imperatively through
|
|
512
|
+
`handle.executeCommand(command)`; every command exists only while its capability is declared (`getCellFormat` for formats /
|
|
513
|
+
borders / colours, `getCellNote` / `getCellComments` / `getCellHyperlink` / `getCellValidation` for the popover commands, the
|
|
514
|
+
`formats` prop for `formatCurrency`, `getCellFormula` for `pasteFormulas`) — an unavailable command's chord passes through
|
|
515
|
+
untouched and `executeCommand` returns `false`. The editing columns consult only the 16 **editing-column commands**
|
|
516
|
+
(`bold` / `italic` / `underline` / `strikethrough` / `alignLeft` / `alignCenter` / `alignRight` / `formatNumber` / `formatTime` /
|
|
517
|
+
`formatDate` / `formatCurrency` / `formatPercent` / `formatScientific` / `fillSelection` / `insertDate` / `insertTime`); any other
|
|
518
|
+
command chord pressed while editing is native. The exported `DEFAULT_SHORTCUTS` (`{ [command]: readonly string[] }`, `[]` =
|
|
519
|
+
unbound: 34 commands bound over 36 chords, 36 unbound) and `SPREADSHEET_RESERVED_SHORTCUTS` (the 63 exact chords of the
|
|
520
|
+
non-rebindable built-in rows consulted while idle) are derived from the same `BUILTIN_ROWS` table.
|
|
521
|
+
|
|
522
|
+
- **Grammar** (`parseChord`, canonical spelling only): `[Ctrl+][Alt+][Shift+]KEY` in that fixed order (Cmd counts as Ctrl).
|
|
523
|
+
`KEY` is a bare Latin letter `A`–`Z`, a digit by code (`Digit0`–`Digit9`, `Numpad0`–`Numpad9`), or a `KeyboardEvent.code` name
|
|
524
|
+
(`Semicolon`, `Backslash`, `IntlYen`, `IntlRo`, `Minus`, `Equal`, `F1`–`F12`, `ArrowDown`, `Enter`, `Escape`, `Space`, …). Letters
|
|
525
|
+
resolve by the **produced character** when `event.key` is a single Latin letter, else by the letter of `event.code` (so RU
|
|
526
|
+
`Ctrl+и` on `KeyB` is `Ctrl+B`, macOS `Ctrl+∂` on `KeyD` is `Ctrl+D`); everything else resolves by `event.code`, so a synthetic
|
|
527
|
+
keydown must carry `code`. A row containing `Alt` is never claimed when `getModifierState("AltGraph")` is true. The idle
|
|
528
|
+
column's native letter rows (`Ctrl+A`, `Ctrl+Z`, `Ctrl+Shift+Z`, `Ctrl+Y`, `Ctrl+Shift+V`) are matched by the same rule
|
|
529
|
+
(`matchChord`), so they fire on non-Latin layouts (RU `Ctrl+Ф` selects all) exactly like the command rows.
|
|
530
|
+
- **Prop shape**: `shortcuts?: { [command]?: readonly string[] | null }` — an array **replaces** that command's default list
|
|
531
|
+
(union is not supported), `null` unbinds it, a single string is a `RangeError`.
|
|
532
|
+
- **Validation** (`validateShortcuts`, during render — like `zoom`): `RangeError` on a non-canonical spelling (`ctrl+B`,
|
|
533
|
+
`Shift+Ctrl+B`, `Ctrl+b`), a modifier-less chord, a single string, an unknown command key, any override of `pasteSpecial`, a
|
|
534
|
+
chord equal to a non-rebindable built-in row in a column where the command is consulted (`bold: ["Alt+Enter"]` clashes with the
|
|
535
|
+
editing-column newline row; `openHyperlink: ["Alt+Enter"]` is legal because that command lives in the idle column only), and one
|
|
536
|
+
chord bound to two commands after the merge (`{ bold: ["Ctrl+I"] }`). Families are NOT reserved: `Alt+ArrowDown`, `Alt+Enter`,
|
|
537
|
+
`Ctrl+Enter` and `Shift+F2` are valid defaults, and `Ctrl+Alt+Shift+Digit9` is a valid binding for `hideColumns`.
|
|
538
|
+
- **Layout notes**: on AZERTY the physical `Semicolon` key reports the letter `m`, so `Ctrl+Semicolon` / `Ctrl+Shift+Semicolon`
|
|
539
|
+
(`insertDate` / `insertTime`) are unreachable there (no wrong command fires — Google Sheets has the same gap); JIS keyboards get
|
|
540
|
+
`clearFormatting` on `Ctrl+IntlYen` / `Ctrl+IntlRo` in addition to `Ctrl+Backslash`.
|
|
541
|
+
|
|
542
|
+
Keymap notes:
|
|
543
|
+
|
|
544
|
+
- **Ctrl+Arrow data edges** need the adapter's optional `findDataEdge` (see the adapter contract); without it the v0.1
|
|
545
|
+
grid-edge jump is kept. Ctrl+Shift+Arrow scans from the Excel-faithful hybrid coordinate (the active cell's row/column,
|
|
546
|
+
the extension corner's progression component) and extends only the progression axis, so an existing 2-D extension is
|
|
547
|
+
never collapsed. A data-edge landing on a hidden track rescans forward first, then backward when nothing is visible
|
|
548
|
+
ahead, and stays put only when neither side has a visible track (deviation — Excel can land on hidden cells).
|
|
549
|
+
- **Ctrl+Space may never reach the page** in some environments — OSes and IMEs commonly bind it (e.g. input-source
|
|
550
|
+
switching).
|
|
551
|
+
- **Ctrl+Shift+Space is not bound** (it falls through per the legend) — use Ctrl+A for select-all.
|
|
552
|
+
- Shift+Space previously (v0.1) started replace editing with a space while idle; it now selects rows (Excel behavior).
|
|
553
|
+
- Tab/Enter cycling does **not** skip hidden cells (see deviations) — the visible stepping above covers arrows, Home/End,
|
|
554
|
+
and paging.
|
|
555
|
+
- **Reference pointing** (needs `getCellFormula`): inside a formula session, whenever the text before the caret ends with a
|
|
556
|
+
trigger — `=`, `(`, `,`, `;`, an operator or `:` (string literals blanked) — or with a trigger followed by a reference token
|
|
557
|
+
(that token is then the replace target), a primary-button press on a cell inserts its reference (`A1`) at the caret and a drag
|
|
558
|
+
replaces it with the rectangle (`A1:C3`); a column / row header press inserts `A:A` / `2:2`; Arrow / Shift+Arrow / Ctrl+Arrow
|
|
559
|
+
move a reference cursor from the editing cell — Shift+Arrow extends from the last pointed cell (the extension anchor, so upward /
|
|
560
|
+
leftward chains keep growing the range: `C4` → `C3:C4` → `C2:C4`; a hand-typed token anchors at its start, Excel parity); a
|
|
561
|
+
printable character closes the token, F4 cycles the `$` anchors, Escape during
|
|
562
|
+
a reference drag restores the token and keeps the session. Pointing is **armed** by typing that leaves the caret right after a
|
|
563
|
+
trigger (`=`, `+`, or Backspace back to one — Sheets' operator rule, Excel's Enter mode) or by F2, and F2 toggles it off (arrows
|
|
564
|
+
move the caret) and back on. A session opens **disarmed**: F2 / double-click on an existing `=A1+B1` edits its text and the first
|
|
565
|
+
Arrow moves the caret instead of rewriting `B1` (Excel Edit mode). The selection, the active cell and the editor's position
|
|
566
|
+
never change while pointing; the corner, resize strips, fill handle, scrollbar bands and zoom dead space are consumed without
|
|
567
|
+
inserting anything. A grid-exit focus-out commits the partial formula as typed.
|
|
568
|
+
|
|
569
|
+
## Props reference
|
|
570
|
+
|
|
571
|
+
| Prop | Type (abridged) | Notes |
|
|
572
|
+
| :--- | :--- | :--- |
|
|
573
|
+
| `adapter` | `SpreadsheetDataAdapter` | Required. Object identity = sheet identity. |
|
|
574
|
+
| `dataVersion` | `number` | Bump after external writes (undo, collab). Re-renders visible cells and disarms a pending cut. |
|
|
575
|
+
| `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). |
|
|
576
|
+
| `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. |
|
|
577
|
+
| `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. |
|
|
578
|
+
| `renderCell` | `(ctx: RenderCellContext) => ReactNode` — `RenderCellContext = { row; col; text }` (exported) | Custom cell rendering. **Must be identity-stable** (`useCallback`) — an inline arrow defeats the cell memo and re-renders every visible cell each pass. |
|
|
579
|
+
| `maxOperationCells` | `number` | Bulk-operation ceiling (default 100,000). Row fan-out resize gates at 1/10 of it and autofit at 1/4 — see "Operation limits". |
|
|
580
|
+
| `enableResizeUI` | `boolean` | Default `true`. `false` removes resize strips **and unhide markers** — hidden tracks then have no in-package restore path (imperative API only). |
|
|
581
|
+
| `zoom` / `defaultZoom` / `onZoomChange` | `number` / `number` / `(zoom: number) => void` | Package zoom (v0.3): visual transform scale, logic stays in logical px. Controlled (`zoom`) or uncontrolled (`defaultZoom`, default 1). Gestures — Alt+Wheel and Ctrl+Alt+`=` / `+` / `-` / `0` (reset; the four keys match the produced character, Shift-agnostic; AltGraph-state chords are never claimed — legacy-shape defense in depth, see Keymap notes) — request via `onZoomChange`; Ctrl+Wheel is deliberately unbound (the browser owns it). Clamped to [0.25, 4]; a non-finite value (NaN / ±Infinity) throws a `RangeError` (fail-fast — no silent substitution). |
|
|
582
|
+
| `enableFillHandle` | `boolean` | Default `true`. Fill writes through `applyEdits` — disable for read-only adapters. |
|
|
583
|
+
| `onSelectionChange` | `(selection: SelectionState) => void` | Fires on confirmed changes only — never on mount, and never mid-drag with one documented exception: the header subtract reservation converts to a real press on the first movement that both really changes the pointer coordinates and lands on a different header index (in-index jitter and synthetic same-position moves keep the reservation), firing the deferred press replace mid-drag (total fire count matches v0.1). |
|
|
584
|
+
| `onOperationRejected` | `(op, cellCount, reason) => void` | `op: SpreadsheetOperation` (`"copy" \| "cut" \| "delete" \| "paste" \| "fill" \| "resize" \| "format" \| "calc" \| "annotate"` — the three v0.8 names widen the callback's parameter, a 0.x-breaking change for consumers that annotated a narrower `op`), `reason: "limit" \| "bounds"` — count definitions and per-op ceilings under "Operation limits". |
|
|
585
|
+
| `onEditRejected` | `(error: unknown, rejection: EditRejection) => void` | Fires once per rejected `applyEdits`. `rejection.source` names the refused write path (`"edit"`, or the `SpreadsheetOperation` write names `"paste"` / `"fill"` / `"delete"` / `"format"` / `"calc"` / `"annotate"` — the same vocabulary as `onOperationRejected`; a cut-move is the Ctrl+V gesture and reports `"paste"` in both callbacks, and the source-side clear of a cross-sheet move reports `"paste"` too while the target write stands); for `"edit"`, `rejection.editSession` is `"kept"` (in-grid commit — the editor is still open with focus, Escape cancels) or `"discarded"` (focus left the grid — the grid closes the session as this rejection completes: once the callback returns the typed value is gone and the hidden input is re-armed without moving focus — it stays wherever the focusout left it: on the element the user chose, or still on the hidden input after a window blur; do not advise Escape). See "Editing, Undo, and rejection". |
|
|
586
|
+
| `onCopyMultiRange` | `() => void` | Notice that a multi-range copy serialized only the tail range. |
|
|
587
|
+
| `onCellContextMenu` | `(cell, origin) => void` | Pointer or keyboard (`Shift+F10`) origin; no package menu. |
|
|
588
|
+
| `onUndoRequest` / `onRedoRequest` | `() => void` | `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` relays. |
|
|
589
|
+
| `formatCellAnnouncement` / `formatSelectionAnnouncement` | formatter | Opt-in screen-reader announcements (see above). |
|
|
590
|
+
| `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. |
|
|
591
|
+
| `labels` | `SpreadsheetLabelOverrides` (any subset of the 24 `SpreadsheetLabels` keys) | v0.8 — replaces the `sinkAriaLabel` / `rootAriaLabel` props (folded in) and names every popover / indicator string (`noteTitle`, `noteSave`, `noteEscapeHint`, `noteRejectedHint`, `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`, `validationRejected`, `popoverClose`, `indicatorNote`, `indicatorComment`, `indicatorInvalid`). Omitted / `undefined` keys keep the English `DEFAULT_LABELS`; `sinkAriaLabel` must mention `Escape` (`RangeError` during render otherwise). |
|
|
592
|
+
| `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. |
|
|
593
|
+
| `colors` | `{ fill?: readonly string[]; font?: readonly string[] }` | v0.8 — the five `#rrggbb` slots of `fillColorCustom1..5` / `fontColorCustom1..5` (exactly 5 six-digit hex strings per axis, `RangeError` otherwise); an omitted axis uses `DEFAULT_CUSTOM_COLORS` (`#FF0000` / `#00FF00` / `#0000FF` / `#FFFF00` / `#FF00FF`, exported). |
|
|
594
|
+
| `formats` | `{ currency: string; locale?: string }` | v0.8 — the host currency (ISO code) and rendering locale for `formatCurrency` and for recovering bare currency symbols (`$`, `¥`) in pasted Excel / Sheets HTML; undeclared ⇒ `formatCurrency` passes through and bare symbols are not recovered. |
|
|
595
|
+
| `highlight` / `defaultHighlight` / `onHighlightChange` | `HighlightState` = `{ row: boolean; column: boolean; intersection: boolean }` / same / `(h) => void` | v0.8 — active row / column / intersection highlight bands, controlled (`highlight` + `onHighlightChange`) or uncontrolled (`defaultHighlight`), the same two-phase contract as `zoom`. Default all off ⇒ zero band nodes (v0.7 DOM identity). Toggled by the `toggle*Highlight` commands. When exactly ONE family (row XOR column) is on and `intersection` is off, the cross node lights up tinted with that family's colour so the band runs unbroken through the selection (sheets-enhancer parity — with both families on the hole is kept). A whole-row / whole-column selection (row / column header press, Shift+Space / Ctrl+Space) draws no row / column bands — only the intersection — so the cross-axis band never tints the rest of the sheet. |
|
|
596
|
+
| `highlightColors` | `HighlightColors` = `{ row; column; intersection; opacity }` | v0.8 — identity-stable band palette written to `--aqsp-band-row` / `-column` / `-intersection` / `-opacity` on the overlay in a layout effect; each colour must parse (`normalizeColor`), `opacity` must be a finite number in [0, 1] (`RangeError` otherwise). Undeclared ⇒ nothing is written while every band is off and `DEFAULT_HIGHLIGHT_COLORS` (`#0084FF` / `#42FF4F` / `#E1FF00` / 0.2, exported) is written once a band turns on. |
|
|
597
|
+
| `popovers` | `PopoverOverrides` = `{ [kind]?: boolean }` (`note` / `note-tip` / `comment` / `link` / `list` / `message`) | v0.8 — `false` disables a kind: its commands pass through and `handle.openPopover(kind)` throws a `RangeError`. The popover layer mounts only when at least one kind is enabled AND its capability is declared. |
|
|
598
|
+
| `renderPopover` | `(ctx: PopoverContext) => ReactNode` | v0.8 — replaces the default popover components; `ctx.kind` discriminates the six contexts (`NotePopoverContext` … `MessagePopoverContext`, exported) and the defaults (`NotePopover` / `NoteTip` / `CommentPopover` / `LinkPopover` / `ListPopover` / `MessagePopover`) are exported so a renderer can replace one kind and delegate the rest. |
|
|
599
|
+
| `onClipChange` | `(clip: ClipInfo \| null) => void` | v0.8 — armed clip notice: `{ kind: "copy" \| "cut", range, foreign }` on arm, `null` on discard, `foreign: true` for a cut kept across a sheet switch. Idempotent (no call when nothing changes). |
|
|
600
|
+
| `onHyperlinkOpen` | `(url: string, cell: CellCoord) => void` | v0.8 — navigation seam for `Alt+Enter`, the link popover's Open button and Ctrl / Meta+click on a link cell; `url` is always a normalised allow-listed href (`normalizeHyperlink` output — an adapter URL outside `http:` / `https:` / `mailto:` / `tel:` never reaches this seam, so `window.open(url)` is safe) (the click is consumed with the selection frozen; Ctrl+Shift stays range-extend); undeclared ⇒ no navigation, no Open button and no pointer gesture. |
|
|
601
|
+
| `onValidationRejected` | `(ctx: { cell; rule; text; message }) => void` | v0.8 — a `strict` validation rule refused a changed commit (the adapter was not called; the message popover shows `message`). |
|
|
602
|
+
| `onPasteSpecialRequest` | `(origin: { kind: "keyboard"; cellRect }) => void` | v0.8 — `Ctrl+Alt+V` asks the host for its Paste Special UI (the active cell's viewport rect in visual px, the `onCellContextMenu` keyboard shape); undeclared ⇒ `Ctrl+Alt+V` is a formats-only paste through the menu path. |
|
|
603
|
+
| `onClipboardUnavailable` | `(cause: "insecure-origin" \| "permission-denied" \| "empty" \| "unreadable") => void` | v0.8 — the menu path's refusal channel (`handle.paste`, `Ctrl+Alt+V` without a request handler, the `paste*` commands); whenever it fires the paste resolves `"rejected"`. `"unreadable"` also fires on the paste event when the detected source cannot be parsed (the package never slides to another reader). |
|
|
604
|
+
| `now` | `() => number` | v0.8 — clock seam of `insertDate` / `insertTime` (declared default `Date.now`). |
|
|
605
|
+
| `testId` / `className` | `string` | Test hook / root class extension. |
|
|
606
|
+
|
|
607
|
+
## Imperative handle
|
|
608
|
+
|
|
609
|
+
```tsx
|
|
610
|
+
const ref = useRef<SpreadsheetHandle>(null)
|
|
611
|
+
// <Spreadsheet ref={ref} ... />
|
|
612
|
+
ref.current?.resizeRow(1, 48)
|
|
613
|
+
ref.current?.resizeColumn(0, 180)
|
|
614
|
+
ref.current?.scrollToCell({ row: 0, col: 5_000_000 }, { alignX: "end" })
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Sizes are integer pixels (rounded once). The imperative resize calls are the raw seam beneath the pointer resize UI and
|
|
618
|
+
carry **none of its safety gates**: no 1px last-visible clamp, no 1,000-hidden-row cap, no fan-out ceiling. Mass-hiding
|
|
619
|
+
rows through this API is the consumer's responsibility and falls outside the rendering guarantee.
|
|
620
|
+
|
|
621
|
+
`scrollToCell(cell, options?)` is a programmatic viewport jump (delegating to the engine's floor-and-pin cell scroll);
|
|
622
|
+
`ScrollToCellOptions` carries `alignX` (`start` / `end` / `center`), `alignY` (`top` / `bottom` / `center`), and
|
|
623
|
+
`offsetX` / `offsetY`. It does **not** change the selection — it is a pure viewport move, and the deep grid stays
|
|
624
|
+
reachable at any scale (10^9 px). The demo's `/huge` route wires it to the "deep jump" button.
|
|
625
|
+
|
|
626
|
+
Per-column *initial* widths are also this seam's job since v0.4: apply them once from a mount effect
|
|
627
|
+
(`useLayoutEffect(() => { widths.forEach((w, col) => ref.current?.resizeColumn(col, w)) }, [])`) — the demo's `/tune`
|
|
628
|
+
route is the reference wiring.
|
|
629
|
+
|
|
630
|
+
The v0.8 handle members are the toolbar seam (every writer returns `true` on commit, `false` on a gate / adapter / commit-gate
|
|
631
|
+
refusal, and throws a `RangeError` when the capability it needs is undeclared):
|
|
632
|
+
|
|
633
|
+
```tsx
|
|
634
|
+
ref.current?.executeCommand("bold") // exactly what the chord does (false = unavailable / refused)
|
|
635
|
+
ref.current?.applyFormat({ fontSize: 14, background: null }) // selection-wide CellFormatPatch (null clears a key), source "format"
|
|
636
|
+
ref.current?.setBorders("outer", { style: "medium" }) // "outer" | "all" | "clear" | "top" | "right" | "bottom" | "left"
|
|
637
|
+
ref.current?.setHyperlink("https://example.com") // active cell, normalised; null clears; javascript: is a RangeError
|
|
638
|
+
ref.current?.setNote("Reviewed") // active cell; null clears
|
|
639
|
+
ref.current?.addComment({ text: "LGTM" }) // appends to the active cell's thread (existing ids kept — you mint the new one)
|
|
640
|
+
ref.current?.setValidation({ type: "list", values: ["A", "B"], strict: true }) // every selected cell; null clears
|
|
641
|
+
await ref.current?.paste({ mode: "values" }) // menu path: Promise<PasteOutcome> = "applied" | "empty" | "rejected"
|
|
642
|
+
ref.current?.getBaseFormat(cell) // the adapter's format (toolbar state)
|
|
643
|
+
ref.current?.getPaintedFormat(cell) // base ⊕ conditional formats (what the user sees)
|
|
644
|
+
ref.current?.getSelection()
|
|
645
|
+
ref.current?.startEdit("edit") // opens the editor without the virtual-keyboard invitation
|
|
646
|
+
ref.current?.openPopover("note") // RangeError when the kind is disabled / its capability undeclared
|
|
647
|
+
ref.current?.focus({ preventScroll: true }) // focuses the hidden input and re-arms it when idle
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
`paste(options)` reads the clipboard through `navigator.clipboard.read({ unsanitized: ["text/html"] })` (one call): a missing
|
|
651
|
+
`navigator.clipboard` is `"insecure-origin"`, a refused read `"permission-denied"`, no readable item `"empty"`, and a detected source
|
|
652
|
+
that fails to parse `"unreadable"` — each fires `onClipboardUnavailable` and resolves `"rejected"`. `"applied"` means the adapter was
|
|
653
|
+
called; `"empty"` means nothing was written (an own payload whose identity was suppressed, every cell skipped, a mode without cell
|
|
654
|
+
aspects such as `columnWidths` — sizes may still have been applied).
|
|
655
|
+
|
|
656
|
+
## Clipboard utilities (public)
|
|
657
|
+
|
|
658
|
+
`parseTsv` / `serializeTsv` / `estimatePasteCellCount` / `expandPaste` are exported for building custom paste UIs and
|
|
659
|
+
importers with the exact same Excel-compatible semantics the grid uses. `enumerateRangeCells`, `makeRange`,
|
|
660
|
+
`rangeContains`, `rangeSize`, `columnIndexToName`, `columnNameToIndex` round out the toolkit. The named unions
|
|
661
|
+
`SpreadsheetDirection` (`"up" | "down" | "left" | "right"` — the `findDataEdge` direction), `SpreadsheetOperation` (the
|
|
662
|
+
`onOperationRejected` op), and `EditRejection` (the `onEditRejected` context — see "Editing, Undo, and rejection") are exported for
|
|
663
|
+
consumer-side annotations.
|
|
664
|
+
|
|
665
|
+
v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 39 values and 94 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):
|
|
666
|
+
|
|
667
|
+
- **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`),
|
|
668
|
+
`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).
|
|
669
|
+
- **HTML interop** — `serializeHtmlTable(payload, clipJson)` (the interop table Excel / Sheets read: `data-aqsp-clip` first attribute, `<td style='white-space:pre'>`, `mso-number-format`, `x:num` / `x:str` / `x:fmla`, `data-sheets-value` / `-formula` / `-numberformat` / `-hyperlink` / `-note`) and `parseHtmlTable(html, kind, { currency?, locale? }, maxCells)` (a total reader of Sheets / Excel / generic HTML tables — `{ outcome: "payload", payload }`, `{ outcome: "limit", cells }` as soon as the lower bound of `rows × cols` exceeds `maxCells` (checked
|
|
670
|
+
before every allocation, so the reader never holds more than `maxCells` cells — and rows are pulled lazily through the sibling links, so the stop also ends the row walk), `{ outcome: "none" }` when there is no table; `maxCells` must be a positive integer or the call throws a `RangeError` before any `DOMParser` is built; its parameter types `HtmlTableKind` / `NumberFormatDecodeOptions`).
|
|
671
|
+
- **Formats** — `canonicalizeFormat` / `mergeFormat` / `formatEquals`, `BORDER_DEFAULT_COLOR`, and the types `CellFormat` / `CellFormatPatch` / `CellBorder` / `CellBorders` / `CellBorderStyle` / `CellVerticalAlign` (plus the v0.7 `CellAlign` / `CellValueFormat` / `formatCellValue`).
|
|
672
|
+
- **Formulas** — `shiftFormula(formula, dRow, dCol)` (relative references only, `#REF!` below zero), `toR1C1(formula, origin)` / `fromR1C1(r1c1, origin)` (the clip's origin-relative formula form, Google Sheets spelling `R[-1]C[0]`).
|
|
673
|
+
- **Annotations and validation** — `normalizeHyperlink(text)` (`http:` / `https:` / `mailto:` / `tel:` only, WHATWG-serialised href or `null`), `validateCellText(rule, text, evaluate?)` with the types `DataValidationRule` (its base `ValidationRuleBase`, the seven members `ListValidationRule` / `NumberValidationRule` / `TextLengthValidationRule` / `DateValidationRule` / `TextValidationRule` / `CheckboxValidationRule` / `CustomValidationRule`,
|
|
674
|
+
the op vocabularies `ValidationCompareOp` / `ValidationTextOp` and the evaluator `ValidationEvaluate`) / `ValidationVerdict` / `CellComment` / `CellCommentInput` / `CellPatch` / `CellEdit` / `EditSource` / `EditContext` / `PlainEditContext` / `PasteEditContext`.
|
|
675
|
+
- **Conditional formats** — the types `ConditionalFormatRule` / `ConditionalFormatRuleInput` / `ConditionalFormatStyle` / `CfCondition` (its six members `CellValueCondition` / `TextCondition` / `EmptyCondition` / `NotEmptyCondition` / `ColorScaleCondition` / `CustomFormulaCondition` and the `text` op vocabulary `CfTextOp`) / `CompareOp` / `ScalePoint` / `RangeExtrema` / `FormulaValue`.
|
|
676
|
+
- **Commands and labels** — `DEFAULT_SHORTCUTS`, `SPREADSHEET_RESERVED_SHORTCUTS`, `parseChord`, `DEFAULT_CUSTOM_COLORS`, `DEFAULT_LABELS`, `resolveLabel(labels, key, n?)`, and the types `SpreadsheetCommand` / `ShortcutMap` / `ShortcutOverrides` / `ParsedChord` / `SpreadsheetLabels` / `SpreadsheetLabelOverrides` / `BorderPreset`.
|
|
677
|
+
- **Paste Special** — the types `PasteMode` / `PasteOperation` / `PasteOptions` and the `handle.paste` outcome `PasteOutcome` (`"applied" | "empty" | "rejected"`).
|
|
678
|
+
- **Popovers and bands** — the six default components `NotePopover` / `NoteTip` / `CommentPopover` / `LinkPopover` / `ListPopover` / `MessagePopover` (with their `*Props`), the types `PopoverKind` / `PopoverOverrides` / `PopoverContext` and its six members (their `anchorRect` is a `PopoverRect`), `DEFAULT_HIGHLIGHT_COLORS` and the types `HighlightState` / `HighlightColors`.
|
|
679
|
+
|
|
680
|
+
The `/utils` demo route exercises the v0.1 toolkit live; the `/styles` route drives the v0.8 engines through the grid itself (own copy / paste round trips, Excel / Sheets fixture pastes, commands and popovers).
|
|
681
|
+
|
|
682
|
+
## Deviations from Excel (deliberate)
|
|
683
|
+
|
|
684
|
+
| Behavior | Here | Excel | Rationale |
|
|
685
|
+
| :--- | :--- | :--- | :--- |
|
|
686
|
+
| Diagonal fill | The handle fills the L-shaped box in one gesture (2D tiling) | Axis-constrained per drag | Requested super-set; single-axis drags behave identically to Excel |
|
|
687
|
+
| In-cell editor size | Fixed to the cell box (Alt+Enter lines beyond the cell height are clipped while editing; committed text still stores every line) | The in-cell editor grows to show all lines | A growing editor needs its own layout machinery; the canonical value is unaffected |
|
|
688
|
+
| Editor while scrolled away | The visible editor disappears once the edited cell's top-left corner leaves the viewport (session and value survive; it re-shows on scroll-back) | The editor scrolls off with its cell, partially clipped | The sink is viewport-clamped for focus/IME survival — showing it clamped would paint an editor over the wrong cells |
|
|
689
|
+
| Zoom shortcuts | Alt+Wheel / Ctrl+Alt+`=` `+` `-` `0` (produced-character match, Shift-agnostic) | Ctrl+Wheel | The browser owns Ctrl+Wheel in a web page — a conflicting binding would fight page zoom |
|
|
690
|
+
| Multi-range copy | Tail range only + `onCopyMultiRange` notice | Aligned ranges concatenate | Ambiguous general case; explicit callback instead of silent difference |
|
|
691
|
+
| Cut paste clipping | Rejected entirely (`"bounds"`) | Rejects likewise | Clipping a move would silently destroy the clipped cells |
|
|
692
|
+
| Ctrl+Arrow without `findDataEdge` | Grid edge | Data edge | Data-end scan is a store capability — declare `findDataEdge` for Excel behavior |
|
|
693
|
+
| Ctrl+Arrow landing on a hidden cell | Rescans forward first, then backward, else stays | Can land on the hidden cell | Forward keeps data beyond a hidden edge reachable; the backward snap only fires when nothing is visible ahead |
|
|
694
|
+
| Ctrl+End / Ctrl+Shift+End | Visible grid corner | Used-range corner | Consistent grid-edge family (used-range tracking is the store's domain) |
|
|
695
|
+
| Shift+End | Extends to the row's last visible column (Sheets-style) | Toggles End mode | An End-mode state machine is out of scope |
|
|
696
|
+
| Ctrl+Arrow while editing | Native word caret | Commit + jump | Caret ergonomics win inside a text field |
|
|
697
|
+
| Double-click caret | Clicked character position (real browsers, default rendering) | Clicked character position | Falls back to end-of-text **per cell** when `renderCell` is set, when the cell's formatted display differs from its raw value (the hit offset would mis-map onto the raw text), or when the caret-position API is unavailable — text/label/unformatted cells of a formatted sheet keep the click-point caret |
|
|
698
|
+
| Header right-click | Native menu, no selection | Column select + menu | No package-rendered menu exists to complete the gesture |
|
|
699
|
+
| Escape during drag | Aborts with any modifier held | — | Shift-drag + Escape naturally arrives as Shift+Escape; strict matching would drop the abort |
|
|
700
|
+
| Scrollbar/header press mid-edit | Commits first (commit gate, primary button) | Scrollbar keeps editing | Focus never leaves the sink, so a focusout commit can never fire; an explicit gate is the only correct commit path |
|
|
701
|
+
| Shrinking Shift+Arrow with off-anchor active | Active clamps back into the tail range **only when it lands in no range at all** (an active living in another selected range stays put) | Active can strand outside | A stranded active corrupts cycling and paste origins; multi-range cycling needs the relaxed rule |
|
|
702
|
+
| Enter after cut | Cycles in range (paste is `Ctrl+V` only) | Enter completes the cut-move | One paste channel; Enter keeps its navigation meaning |
|
|
703
|
+
| Second `Ctrl+V` after a cut-move | Pastes again as a plain copy | Clipboard consumed by the move | The OS clipboard still holds the text; re-paste is the least surprising reading |
|
|
704
|
+
| macOS `Option`+char | Does not start editing | Types the composed character | `Alt`-modified keys are indistinguishable from shortcuts at the keydown layer; deferred (§14) |
|
|
705
|
+
| Mid-drag non-primary release | Only the primary button's release confirms a drag (a right-button release mid-drag confirms nothing; v0.1 confirmed it); releasing the primary first while a chord is held — delivered as a pointer move — confirms the drag (cell / header / resize at that position, fill at its last painted preview band — the same box its `pointerup` confirms) | Only the left release confirms | Excel-parity fix — a chorded right click must not commit a half-built selection |
|
|
706
|
+
| Fill inward drag | Extension only — collapsing the band back into the source makes the release a no-op | Clears the re-covered cells | Inward-clear planned (roadmap) |
|
|
707
|
+
| Fill semantics | Cyclic copy fill only | Series / smart fill (1, 2 → 3, 4...) | Pattern detection is a locale-dependent domain of its own |
|
|
708
|
+
| Fill-handle double-click | No-op | Fills down to adjacent data | Needs composition with `findDataEdge` (roadmap) |
|
|
709
|
+
| Copy / TSV of formatted cells | Carries the **raw** canonical `getCellText` | Copies the **display** string into `text/plain` | Raw preserves the numeric round-trip (paste back, spreadsheets, importers); the display string is a lossy projection |
|
|
710
|
+
| Too-narrow formatted cells | Content clips (right-aligned cells clip on the **left**, Sheets-style); autofit now measures the formatted display, preventing the common case | Renders `####` for numeric cells that don't fit | A `####` substitute needs per-cell width probing on every paint; clipping keeps rendering O(visible) |
|
|
711
|
+
| Autofit scope | Visible-window measurement (columns) / default-height restore (rows) | Scans all data / wrap-measures row heights | Scanning 1M rows would freeze; cells render single-line clipped |
|
|
712
|
+
| Autofit / dblclick caret with `renderCell` | Disabled (no-op / caret at end) | — | Custom cell DOM cannot be measured or offset-mapped |
|
|
713
|
+
| Same-value resize release | No-op | Equalizes all selected columns to the dragged width | A no-move release should change nothing |
|
|
714
|
+
| Hiding everything | The last visible track of an axis clamps to 1px | Allows hiding all columns/rows | No select-all + Unhide menu exists here — an all-hidden grid would be unrecoverable |
|
|
715
|
+
| Hidden-row volume (UI) | Hides pushing the axis-wide hidden-row total past 1,000 are rejected (`"bounds"`) | Unlimited | Rendering-window structural constraint; upstream removal planned |
|
|
716
|
+
| Fan-out resize / autofit ceilings | Rejected past the "Operation limits" caps | Resizes any selection size | Freeze prevention ("never silently freezes") |
|
|
717
|
+
| Resize grab zone | 6px strip inside the boundary only | Zone straddles the boundary | The next header's first pixels stay selectable |
|
|
718
|
+
| Hidden-boundary double-click | Unhide marker restores the default size | Autofit-family behavior | Restore affordance kept simple |
|
|
719
|
+
| Header Ctrl+drag subtract | Not supported (Ctrl+click toggle only; cell-surface Ctrl+drag subtract exists) | Drag-subtract on headers | Scope cut |
|
|
720
|
+
| Tab / Enter cycling over hidden cells | Passes through (active can land on a hidden cell) | Skips hidden cells | Visible-aware cycling planned (roadmap) |
|
|
721
|
+
| Hide / unhide hotkeys | Sheets chords: `Ctrl+Alt+Digit9` hides rows, `Ctrl+Shift+Digit9` / `Ctrl+Shift+Digit0` unhide rows / columns; `hideColumns` is **unbound by default** (bind it through `shortcuts`) | `Ctrl+9` / `Ctrl+0` / `Ctrl+Shift+9` / `Ctrl+Shift+0` | Sheets' `Ctrl+Alt+0` sits in the reserved zoom token space; Excel's `Ctrl+9` family is taken by browsers (tab switching) |
|
|
722
|
+
| Notes / comments / validation / conditional formats on the clipboard | Travel only in the package's own payload (`data-aqsp-clip`); a Sheets `data-sheets-note` is read on paste, Excel HTML carries no notes | Office-internal formats carry them between workbooks | HTML tables have no standard carrier |
|
|
723
|
+
| Copied values in `text/html` | The visible text is the **display** string; the raw value rides the typed attributes (`x:num`, `data-sheets-value`) — `text/plain` stays raw | Excel writes display strings everywhere | Excel / Sheets read the typed carriers; every other consumer sees what the grid shows |
|
|
724
|
+
| Paste Special of an armed cut | Copy semantics (the cut stays armed) | Paste Special is disabled after a cut | One paste channel; the cut is still the user's intent |
|
|
725
|
+
| `colspan` / `rowspan` in pasted tables | Anchor cell + blank unformatted cells | Merged cells | No merge model in the package |
|
|
726
|
+
| Sheets compact JSON (`data-sheets-userformat` / text-style runs) | Not parsed (the CSS already carries the cell format) | — | No partial-format (run) model |
|
|
727
|
+
| Column widths / row heights on the clipboard | Carried only by whole-column / whole-row copies, pasted by `all` / `noBorders` / `columnWidths`, never by a transposed paste; sizes are **non-undoable geometry** (no `applyEdits`), and every wire / HTML / gated size is capped at 10,000 px (`MAX_TRACK_SIZE_PX`) so a hostile pasted width can never wedge the grid | Pasted for any range incl. transposed | Sizes are outside the `applyEdits` vocabulary |
|
|
728
|
+
| Paste Special ÷ 0 | Target untouched | `#DIV/0!` | No error-value vocabulary |
|
|
729
|
+
| Paste Special results | 15 significant digits, no `-0`, non-finite results leave the target; non-numeric / `TRUE` / `FALSE` source cells are skipped | Excel display precision | No binary artefacts (`0.1 + 0.2` ⇒ `0.3`) |
|
|
730
|
+
| Transposed paste formulas | Only fully relative references rotate; mixed / absolute references stay | Same | A mixed reference cannot rotate without moving its anchor |
|
|
731
|
+
| Fill and comments | `fillDown` / `fillRight` / `fillSelection` / the fill handle never copy comments | Same for comments (notes are copied — and so are they here) | Thread ids are consumer-scoped |
|
|
732
|
+
| Cross-sheet cut-move | Two writes (target, then source clear) under ONE transaction id; the source sheet's conditional-format rules are never moved or removed | One operation that moves rules too | Rules are a sheet-level consumer asset |
|
|
733
|
+
| Conditional-format borders / number formats | Unsupported (`ConditionalFormatStyle` is the Sheets subset: background / colour / bold / italic / underline / strikethrough) | Excel supports both | Border ownership folding and CF composition are undefined |
|
|
734
|
+
| `mso-number-format` over the menu path | Lost when the engine's `read()` strips `<style>` (Chromium keeps it with `unsanitized`); a stripped Excel marker makes the table detect as `html`, whose reader still honours `x:num` / `x:str` / `x:fmla` | — | Browser sanitiser behaviour |
|
|
735
|
+
| `Ctrl+Shift+V` | Fixed values-only chord (not a command) | Same in Sheets | It must ride the native paste event |
|
|
736
|
+
| Zoom chords | `Ctrl+Alt` and `Ctrl+Alt+Shift` × `Equal` / `Minus` / `Digit0` / `NumpadAdd` / `NumpadSubtract` / `Numpad0` are reserved; on layouts producing `=` / `+` / `-` / `0` for another `Ctrl+Alt(+Shift)` chord that chord is shadowed | — | The v0.3.3 produced-character zoom contract is kept byte-identical |
|
|
737
|
+
| A literal starting with `=` | Cannot be stored through the grid when `getCellFormula` is declared (there is no apostrophe escape); copy protects such literals with `x:str` / a string-typed `data-sheets-value` | `'=` escapes it | The escape character is the store's convention |
|
|
738
|
+
| AZERTY `Ctrl+;` | `insertDate` / `insertTime` unreachable (the physical Semicolon key reports the letter `m`) — no wrong command fires | Same gap in Sheets | Letters win in the VK rule |
|
|
739
|
+
| `Ctrl+Alt+V` | Non-rebindable; `onPasteSpecialRequest` when declared, else a formats-only paste (insecure origins report `onClipboardUnavailable`) | Paste Special dialog | The package renders no dialog |
|
|
740
|
+
| Note tip | Hidden by Escape (step 0 of the chain — the clip is untouched) and never shown while any drag (incl. a reference drag) is in flight; re-shown only after the pointer changes cell | Always shown on hover | One press = one action; no DOM churn mid-drag |
|
|
741
|
+
| Partial formula on grid exit | A grid-exit focus-out commits the partial formula as typed | Excel refuses to leave the cell | A session cannot survive without focus (see "Focus leaving the grid") |
|
|
742
|
+
| Highlight bands | At most the last 16 selected ranges get bands | Every range | 1,280 nodes per frame at 64 ranges × 4 quadrants (measured) |
|
|
743
|
+
| Ragged HTML tables | The pre-parse estimate is `rows × max (carried rowspan + Σcolspan)` over the ASCII-case-folded markup — an upper bound of what the reader materialises (a wide header `colspan` can reject a table that is really under the cap); the reader itself stops at `maxOperationCells` before allocating (`outcome: "limit"`), so an attribute the estimate cannot read (`colspan="2"`) is still bounded. Only the `<table` detection is lowercase-only: an all-upper-case `<TABLE>` document pastes as text | — | Conservative like `countRangeCells`; the estimate must never under-count, and nothing super-linear in the cell count runs before the cap |
|
|
744
|
+
| `insertDate` / `insertTime` while idle | Write the active cell only | Sheets writes every selected cell | Avoids accidental overwrites of a multi-range selection |
|
|
745
|
+
| `bordersOuter` / single-side border presets | Position-dependent range-edge application: only the cells on the targeted range edge(s) change, each keeping its own other sides; cells off the edge get no edit | Same (the plan prescribed a constant patch from the active cell to every selected cell) | Excel / Sheets parity; stamping the active cell's side set onto every cell would erase the other cells' borders |
|
|
746
|
+
| `clearFormatting` | Cells that are already unformatted produce no edit | — | Identity rule of `mergeFormat` — no no-op writes reach the adapter |
|
|
747
|
+
| Colour toggles | Compare the active cell's base colour after normalisation (`#ffff00` = `#FFFF00`), write the configured spelling verbatim | — | The target spelling is the consumer's choice |
|
|
748
|
+
| Validation list picker | Case-insensitive **prefix** filter; mounts at most 500 options per render — a larger match set appends one `listMore` row naming the hidden count | Sheets filters by substring and virtualises the list | A pasted foreign rule may carry an arbitrarily large values array; mounting it whole would freeze the tab, and the prefix filter reaches everything beyond the cap |
|
|
749
|
+
| Self-calc | Writes canonical decimals (exponent-form inputs keep exponent form), flips `TRUE` / `FALSE` case-insensitively, leaves custom checkbox labels alone, treats an `=`-prefixed text as text unless `getCellFormula` is declared, and wraps compound formulas (`=(A1)/100+(B1)/100` × 100 ⇒ `=((A1)/100+(B1)/100)*100`) instead of peeling | The reference extension peels greedily | The greedy regex produces the broken `=A1)/100+(B1` |
|
|
750
|
+
| Excel / Word HTML without a table | `mso-` paragraphs (Word / Outlook) paste as text, not as an unreadable Excel clip | — | No table reader can read them; ADR-14 forbids trying another reader |
|
|
751
|
+
|
|
752
|
+
## Demo (feature showcase, doubles as the E2E fixture)
|
|
753
|
+
|
|
754
|
+
Run `pnpm run demo:dev` from a source checkout (the `demo/` app is not in the npm tarball) and open
|
|
755
|
+
`http://localhost:5179`. Nine routes, each an in-header-nav page AND an E2E fixture (`tests/e2e/`):
|
|
756
|
+
|
|
757
|
+
| Route | What you experience |
|
|
758
|
+
| --- | --- |
|
|
759
|
+
| `/` | The headline goal live: 1,000,000 × 16,384 read-only grid, rejection toasts, mount-time metric |
|
|
760
|
+
| `/edit` | Editing, the reference inverse-patch Undo/Redo store, `findDataEdge` Ctrl+Arrow jumps, value-based auto-alignment via a bare `getCellFormat: () => undefined` declaration (typed numbers right-align like Excel), the imperative resize handle, and a Sheet A/B toggle (adapter-identity swap: selection resets to A1, the pending edit and an armed copy are discarded, an armed cut is kept as a foreign clip for a cross-sheet move) |
|
|
761
|
+
| `/render` | `renderCell` formatting (currency / delta / meter / badge) over canonical strings, the selection readout (`onSelectionChange`), a custom context menu on both origins (right-click and Shift+F10), `onCopyMultiRange` |
|
|
762
|
+
| `/format` | The built-in display formats (v0.7): a typed sheet whose columns declare `getCellFormat` (number with grouping / currency ¥ / percent / scientific / date / explicit-center text) with **no** `renderCell` — value-aware auto-alignment, raw-value editing and copy, formatted-display autofit, an **interactive format toolbar** (apply a value format and alignment to the selected cell/range independently, restore the column default — published through the documented `dataVersion` bump), plus the standard read-only wiring (`enableFillHandle={false}` and an `onEditRejected` readout whose wording is derived from the `EditRejection` context — a kept session says Escape cancels, a discarded one says the value is already gone — so Enter is never a silent dead key and a focus-out never gets bad advice) |
|
|
763
|
+
| `/styles` | The v0.8 rich cells (`tests/e2e/styles.spec.ts`): a 300 × 26 sheet whose store declares all eight capabilities (a mini formula evaluator for `=A1+B1` / `SUM` / `AVERAGE` feeds `evaluateFormula` and the computed `getCellText`, `getCellFormula` returns the source), a patch-applying store with per-transaction inverse-patch Undo (a cross-sheet cut-move folds into one entry) and consumer-minted comment ids, a format toolbar driven by `handle.executeCommand` (every button keeps focus in the grid), colour pickers, border / number-format presets, link / note / comment / validation / conditional-format editors, a Paste Special menu opened by `onPasteSpecialRequest` (Excel accelerators — modes `A`ll / `F`ormulas / `V`alues / `T` formats / `C`omments and notes / `N` validation / `X` all except borders / `W`idths / `R` conditional formats, operations `O` none / `D` add / `S` subtract / `M` multiply / `I` divide, flags `B` skip blanks / `E` transpose / `J` inject formats → `handle.paste`), highlight-band settings, a shortcut rebinding table with reserved-chord warnings, a clip-info chip (`onClipChange`), a Sheet A / B toggle for cross-sheet paste and cut-move, and Excel / Sheets fixture buttons that write real interop HTML to the clipboard |
|
|
764
|
+
| `/huge` | The v0.4 deep-reach proof: 1,000,000 × 10,000,000 on a pure functional adapter — Ctrl+→ (and End) reach the rightmost column 9,999,999 and Ctrl+↓ the last row 999,999 in one press (uniform-block Excel semantics, unified with the `/` route; ±1px cell placement), a "deep jump" button (`scrollToCell` to the interior column 5,000,000 ≈ 5 億 px, past the 2^25 wall), zoom ×4 holds ±1 logical px — plus runtime freeze toggles (0 ↔ 3 frozen rows above 0 ↔ 3 frozen columns), a context menu (right-click / Shift+F10) that opens at static frozen-cell coordinates even mid-deep-scroll, and the drag-freshness and performance-budget E2E gates |
|
|
765
|
+
| `/tune` | Sizing knobs (`defaultRowHeight` / `defaultColumnWidth` / banded per-column widths via the imperative `resizeColumn` mount effect), hide / unhide (drag a boundary to 0, marker drag / dblclick restore, single-column dblclick autofit), a deliberately low LIVE `maxOperationCells` staging every rejection class (limit, row fan-out ÷10, autofit ÷4, bounds via the multi-cell corner recipe), live `enableResizeUI` / `enableFillHandle` opt-outs, an empty-grid staging toggle (rowCount 0 adapter swap), a data-reset button, and the zoom card (v0.3) — the controlled-mode reference wiring (`zoom` + `onZoomChange`, 50–200% select plus every zoom gesture) and the fixture for the zoom E2E gates |
|
|
766
|
+
| `/a11y` | Custom Japanese `formatCellAnnouncement` / `formatSelectionAnnouncement` mirrored into visible readouts, overridden accessible names (Escape advisement kept), the WCAG 2.1.2 escape route |
|
|
767
|
+
| `/utils` | The exported pure functions live: TSV round-trip, paste-expansion rules, column-name converter (incl. its fail-fast `RangeError`), range algebra, and a clipboard inspector card (discloses the raw text/html payload, textContent-rendered) |
|
|
768
|
+
|
|
769
|
+
Appending `?css=standalone` to any route swaps the source CSS pair for the built single-file
|
|
770
|
+
artifact `dist/styles/spreadsheet.standalone.css` (artifact-parity verification path — run
|
|
771
|
+
`pnpm run build:css` first).
|
|
772
|
+
|
|
773
|
+
## Performance
|
|
774
|
+
|
|
775
|
+
Budgets are enforced by tests, not prose (`tests/e2e/performance.spec.ts`, `src/clipboard/pasteBudget.spec.ts`):
|
|
776
|
+
initial mount of the 1M × 16,384 demo < 150ms (production build, best of 3 — shared-CI contention only ever adds time);
|
|
777
|
+
zero cell re-renders during drag selection and within-window scrolling (DOM-identity verified); 100,000-cell paste
|
|
778
|
+
parse→expand→apply < 100ms (median of 3, asserted as written under `AQSP_BENCH_SOLO=1` — 300 ms in-suite slack); DOM node count independent of data and selection size. The v0.4 deep-reach
|
|
779
|
+
gates (`tests/e2e/virtualgrid-deep.spec.ts`) add: a 10,000,000-column deep jump paints its landing window < 150ms
|
|
780
|
+
(min of 3) and a horizontal thumb teleport materializes the landing window within 2 vsync.
|
|
781
|
+
|
|
782
|
+
The v0.8 rows (`src/clipboard/pasteBudget.spec.ts`, `src/clipboardRich.spec.tsx`, `src/richCells.spec.tsx`,
|
|
783
|
+
`src/highlightBands.spec.tsx`, `tests/e2e/styles.spec.ts`) are deterministic-first: structural pins (adapter read counts,
|
|
784
|
+
predicate evaluations, dictionary sizes, style-write counts) are the primary gates, and a quoted wall clock is asserted as
|
|
785
|
+
written only under `AQSP_BENCH_SOLO=1` — that solo branch exists in `pasteBudget.spec.ts` (all three wall-clock rows), `richCells.spec.tsx` (every bench
|
|
786
|
+
row, each wall clock in an `it` of its own so a loaded host can only redden the clock, never the structure pins — see below), `styles.spec.ts`, `v2b.spec.ts` and the v0.2 row-fan-out bench
|
|
787
|
+
`resizeBudget.spec.tsx`; the in-suite runs carry slack, and every other jsdom wall clock is a secondary gate
|
|
788
|
+
in an `it` of its own (the bench runs once into a memo both `it`s read) whose floor and slack are stated inline (`highlightBands.spec.tsx` has no wall clock at all): an own 100,000-cell JSON paste < 100 ms with ≤ 100 distinct R1C1 formulas
|
|
789
|
+
(≈ 90 ms measured) and < 300 ms with all-distinct formulas (≈ 220 ms); a 100,000-cell copy (4 formats, 20 % formulas) emits
|
|
790
|
+
`html.length ≤ 160 × cells` and ONE format-dictionary entry for fresh-but-equal format objects; an over-cap ragged HTML table is
|
|
791
|
+
rejected with zero `DOMParser` constructions; painting 1,200 cells against 1,000 whole-column conditional-format rules spread over
|
|
792
|
+
26 column bands stays within eight adapter reads per cell and walks only the cell's 2-D bucket, and the literal 26-adjacent-column
|
|
793
|
+
shape accounts for exactly 71,200 `ranges` reads (both wall clocks sit in separate `it`s as a two-tier secondary gate calibrated on
|
|
794
|
+
the documented jsdom seam floor of 20 ms — 40 ms solo / 100 ms in-suite — because the plan's 8 ms is a Chromium figure the jsdom
|
|
795
|
+
seam never meets: ≈ 18 ms cold median on the B5 host, 3.7 / 13.1 ms on a 12-core host in 2026-09); 1,200 validated
|
|
796
|
+
cells against a 10,000-item list paint their invalid marks with zero formula evaluations and one text read per cell, < 3 ms solo
|
|
797
|
+
(≈ 1.4 ms measured; the in-suite gate is 3 ms × 5 slack, again in an `it` of its own); `getRangeExtrema` is called exactly once per
|
|
798
|
+
value-less colour-scale range per render version (0 on a selection change); band repaints write only `transform` per scroll frame
|
|
799
|
+
(Chromium: per-repaint p90 ≤ 4 ms with 16 ranges and both axes frozen — 1.30 ms measured solo over 320 band nodes); the
|
|
800
|
+
Chromium gates on the 300 × 26 `/styles` demo (7,800 cells — a 100,000-cell own range is dimensionally unreachable there, so
|
|
801
|
+
the "100k" rows are certified whole-sheet, with the foreign table generated in-page) add an own copy of the whole sheet < 150 ms
|
|
802
|
+
(30.4 ms measured solo, `html.length ≤ 160 × cells`), an own `Ctrl+V` of it < 120 ms (27.1 ms), a menu-path paste < 350 ms
|
|
803
|
+
(54.7 ms), a foreign 999 × 100 = 99,900-cell Sheets table < 1.5 s (1,133.9 ms), the 25,000-distinct-string autofit < 300 ms
|
|
804
|
+
(`tests/e2e/v2b.spec.ts`), and a `ctx.font` write-count gate on the same demo (one assignment per distinct painted font,
|
|
805
|
+
conditional-format italics included). The solo inventory above and the operation-limits table's row-axis ceilings are
|
|
806
|
+
themselves pinned by `src/hxCensus.spec.ts` against the spec files that read the variable and against `ROW_FANOUT_DIVISOR`.
|
|
807
|
+
|
|
808
|
+
## License
|
|
809
|
+
|
|
810
|
+
Dual-licensed. Copyright © AI Quants Trading, LLC.
|
|
811
|
+
|
|
812
|
+
- **Noncommercial use** — free of charge under the [PolyForm Noncommercial License 1.0.0](./LICENSE.md)
|
|
813
|
+
(personal, research, educational, charitable, and other noncommercial purposes).
|
|
814
|
+
- **Commercial use** — requires a separate commercial license agreement with AI Quants Trading, LLC.
|
|
815
|
+
|
|
816
|
+
Whatever the distribution channel (registry or repository), the package is provided solely under the dual terms
|
|
817
|
+
above — see `LICENSE.md` for the governing text.
|