@aiquants/spreadsheet 0.16.1 → 0.17.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 CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  All notable changes to `@aiquants/spreadsheet` are documented here.
4
4
 
5
+ ## 0.17.0 (2026-10-27)
6
+
7
+ A host can now fit the grid's box to its content: hide the column letters and row numbers, read the content size, fit
8
+ every column over all its rows, and keep the active cell on screen when the grid is taller than its scroll container.
9
+
10
+ ### Added
11
+
12
+ - **`headings`** (prop, true by default) shows the column letters, the row numbers and the corner (Excel's "headings";
13
+ not `headerRows`). False draws none of the three and collapses their grid tracks to 0, so the cells start at the
14
+ grid's top-left corner. The gestures that live only on them go with them (header presses, the corner's select-all,
15
+ the resize strips and unhide markers), and their keyboard twins stay (Ctrl+Space, Shift+Space, Ctrl+A,
16
+ Alt+Shift+Arrow, Alt+Shift+=). The bands are `aria-hidden`, so assistive technology meets the same grid. The root
17
+ carries `data-aqsp-headings-hidden` while they are hidden, and switching the prop wires the header machines to the
18
+ new bands.
19
+ - **`onContentSizeChange(size)`** (prop) receives the size of the grid's content (`SpreadsheetContentSize`): the bands,
20
+ every track and the two scroll bars the grid always reserves, all scaled by the zoom (the scroll bars live inside the
21
+ zoom wrapper, so they scale too). It is called after the first layout and after every change of a track's size, the
22
+ row or column count, the default sizes, the zoom or `headings`, never with an unchanged size. A host sizes the grid's
23
+ box to its content with it.
24
+ - **`SpreadsheetHandle.autoFitColumns(columns)`** fits the listed columns to the widest text of all their rows (header
25
+ rows included), as Alt+Shift+= fits the active column over the rows in view: the same painted fonts, 9 px padding and
26
+ 10,000 px bound. It refuses through `onOperationRejected("resize", cells, "limit")` when the cells to measure exceed
27
+ `maxOperationCells`, keeps a hidden or empty column's width, and throws a `RangeError` for a column that is not a
28
+ whole number below the column count.
29
+
30
+ ### Changed
31
+
32
+ - **A keyboard move keeps the active cell on screen outside the grid too.** After the grid lands the cell in its own
33
+ viewport, the scroll containers outside the grid (a host's box, the page) scroll by the least amount that shows the
34
+ cell (block and inline "nearest", their `scroll-padding` honoured). A grid taller or wider than its scroll container
35
+ (a box that shows every row, a grid half outside the window) kept the active cell off-screen before, since the grid
36
+ saw it inside its own viewport (WCAG 2.4.11 / 2.4.12). The reveal goes through a hidden marker under the root
37
+ (`data-aqsp-reveal`), clipped to the root's box, so no container inside the grid moves.
38
+ - **Autofit measures with the cells' base font, read from the body pane**, instead of the column-letter band's. Both
39
+ inherit the root's font, so the widths do not change, and the keyboard fit and `autoFitColumns` now work without the
40
+ bands.
41
+
42
+ ### Demo
43
+
44
+ - **`/fit`** shows a host that fits the grid's box to `onContentSizeChange`: the grid shows every row and the page
45
+ scrolls instead, with the headings switch, the zoom, the all-rows fit and the content-size readout.
46
+
47
+ ### Verification
48
+
49
+ - **公開面**: 公開面は `index.ts` の値 44 + 型 99 = 143 export (型 `SpreadsheetContentSize` を追加)。
50
+ - **DOM 属性**: `data-aqsp-headings-hidden` (根) と `data-aqsp-reveal` (ページの追従の印) の 2 種を追加 — リテラル census は
51
+ 83 種 (dataset 直書きの同値抑止署名キー 4 種を加えた総数は 87。分冊 05 §10・分冊 00 §3.2)。
52
+ - **検証 — 走行台帳 (census の錨は本未リリース節)**: 単体 / 統合 **98 ファイル 4,231 テスト** 全緑 —
53
+ 0.16.1 出荷時の 97 ファイル 4,208 に v0.17 進行分 +23 (`hostFit` +20 新規、`toolPages` +2、0.16.1 節の出荷時凍結ピンで
54
+ `hxCensus` +1)。`tsc --noEmit` / biome 0。E2E は `host-fit` 10 テスト (新規) を加えた全 25 ファイル 281 テストを
55
+ 全数実行で確かめた。負荷の中で 1 回落ちた `styles` の 1 本 (書式のツールバーの直後に computed を 1 回だけ読む競合。単独では 3 回とも
56
+ 合格) は、書式と罫線の直後の 6 つの読みを `expect.poll` に直した。
57
+
5
58
  ## 0.16.1 (2026-10-27)
6
59
 
7
60
  ### Added
package/README.md CHANGED
@@ -364,11 +364,12 @@ to `"¥203,428"`, not to the raw `"203428"`, which used to left-clip the most s
364
364
  Row autofit restores the default row height (cells render single-line clipped). Autofit is disabled entirely when
365
365
  `renderCell` is set — custom cell DOM cannot be measured.
366
366
 
367
- > **Autofit measures with the header's font as its base.** Text measurement composes its font from the **header band's**
368
- > computed style (style / weight / size / family); since v0.8 the cell's **painted** format (bold / italic from the base format
369
- > and width-relevant conditional formats, `fontSize` in pt × 4/3, `fontFamily`) overrides those parts per cell, bucketed so
370
- > `ctx.font` is assigned once per distinct font. If your CSS gives body cells a different base font than the headers, autofit
371
- > will mismeasure — keep cell and header base fonts identical (the shipped stylesheets do).
367
+ > **Autofit measures with the cells' base font.** Text measurement composes its font from the **body pane's** computed
368
+ > style (style / weight / size / family — the cells' parent, so the fit works whether `headings` shows the bands or not);
369
+ > the cell's **painted** format (bold / italic from the base format and width-relevant conditional formats, `fontSize` in
370
+ > pt × 4/3, `fontFamily`) overrides those parts per cell, bucketed so `ctx.font` is assigned once per distinct font. If your
371
+ > CSS gives the cells a different base font than their pane, autofit will mismeasure — set fonts on the root, which the
372
+ > pane, the cells and the header bands all inherit (the shipped stylesheets do).
372
373
 
373
374
  `enableResizeUI={false}` removes the strips **and the unhide markers** — a consumer with hidden tracks then has no
374
375
  in-package restore affordance (only the imperative API). The imperative `resizeRow` / `resizeColumn` calls bypass every UI
@@ -896,6 +897,8 @@ The grid's own UI chrome ships in English (`"en"`, the default) and Japanese (`"
896
897
  | `renderCell` | `(ctx: RenderCellContext) => ReactNode` — `RenderCellContext = { row; col; text; displayText }` (exported) | Custom cell rendering. **Must be identity-stable** (`useCallback`) — an inline arrow defeats the cell memo and re-renders every visible cell each pass. **Per-cell opt-out** (v0.12 — ADR-32): return `undefined` and that one cell falls back to the package default rendering, value-format engine included (only `undefined` opts out — `null`, `false` and any other ReactNode are custom output, i.e. an empty custom cell). `ctx.text` is the raw canonical string; `ctx.displayText` is the value-format display — the exact string the default rendering, announcements, the HTML clipboard and autofit share, so a custom cell that renders it can never drift from what gets copied. Autofit stays a whole-grid no-op while `renderCell` is set. |
897
898
  | `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". |
898
899
  | `enableResizeUI` | `boolean` | Default `true`. `false` removes resize strips **and unhide markers** — hidden tracks then have no in-package restore path (imperative API only). |
900
+ | `headings` | `boolean` | v0.17 — default `true`: shows the column letters, the row numbers and the corner (Excel's "headings"; not `headerRows`). `false` draws none of the three and collapses their grid tracks to 0, so the cells start at the grid's top-left corner; the gestures that live only on them go with them (header presses, the corner's select-all, the resize strips and unhide markers) and their keyboard twins stay (Ctrl+Space, Shift+Space, Ctrl+A, Alt+Shift+Arrow, Alt+Shift+=). The bands are `aria-hidden`, so assistive technology meets the same grid. The root carries `data-aqsp-headings-hidden` while they are hidden. LIVE prop. |
901
+ | `onContentSizeChange` | `(size: SpreadsheetContentSize) => void` | v0.17 — receives the size of the grid's content in CSS px: the bands (when shown), every column and row at its current size and the two scroll bars the grid always reserves, all scaled by the zoom (the scroll bars live inside the zoom wrapper, so they scale too). Called after the first layout and after every change of it — a track's size (a resize, an autofit, a hide or unhide, the imperative `resizeRow` / `resizeColumn`), the row or column count, the default sizes, the zoom or `headings` — never with an unchanged size. See *Fitting a box to the grid*. |
899
902
  | `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 [`SPREADSHEET_ZOOM_MIN`, `SPREADSHEET_ZOOM_MAX`] (0.25–4, `clampSpreadsheetZoom` of `@aiquants/spreadsheet/view`); a non-finite value (NaN / ±Infinity) throws a `RangeError` (fail-fast — no silent substitution). |
900
903
  | `enableFillHandle` | `boolean` | Default `true`. Fill writes through `applyEdits` — disable for read-only adapters. |
901
904
  | `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). |
@@ -982,6 +985,39 @@ that fails to parse `"unreadable"` — each fires `onClipboardUnavailable` and r
982
985
  called; `"empty"` means nothing was written (an own payload whose identity was suppressed, every cell skipped, a mode without cell
983
986
  aspects such as `columnWidths` — sizes may still have been applied).
984
987
 
988
+ ## Fitting a box to the grid (v0.17)
989
+
990
+ A host that shows a whole table — a document, a report — sizes the grid's box to the grid's content instead of giving
991
+ the grid a fixed box:
992
+
993
+ ```tsx
994
+ const [size, setSize] = useState<SpreadsheetContentSize | null>(null)
995
+ const box = size === null ? undefined : { width: `min(${Math.ceil(size.width)}px, 100%)`, height: `${Math.ceil(size.height) + 1}px` }
996
+ // <div style={box}><Spreadsheet ref={ref} headings={false} onContentSizeChange={setSize} ... /></div>
997
+ ref.current?.autoFitColumns(Array.from({ length: colCount }, (_, col) => col))
998
+ ```
999
+
1000
+ - **`onContentSizeChange`** reports the size that shows every column and row without scrolling (see the props table).
1001
+ Round the height up and keep a pixel of slack: if a rounding leaves the rows a fraction taller than the box, the row
1002
+ list becomes scrollable and the grid takes vertical finger swipes for itself (`touch-action: none`), so a box taller
1003
+ than the window would trap a touch reader. A box that shows every row lets a vertical wheel and a vertical swipe over
1004
+ the grid scroll the page instead.
1005
+ - **`autoFitColumns(columns)`** fits the listed columns to the widest text of **all** their rows (header rows
1006
+ included) — Alt+Shift+= fits the active column over the rows in view — with the same painted fonts, 9 px padding and
1007
+ 10,000 px bound. A hidden or empty column keeps its width; more measured cells than `maxOperationCells` are refused
1008
+ through `onOperationRejected("resize", cells, "limit")` (false), and a column that is not a whole number below the
1009
+ column count is a `RangeError`. It returns false while `renderCell` is set (custom cell DOM cannot be measured) and
1010
+ works whatever `enableResizeUI` and `headings` say. Measure after `document.fonts.ready` when the page uses web fonts.
1011
+ - **`headings={false}`** drops the column letters, the row numbers and the corner (see the props table).
1012
+ - **Keyboard moves keep the active cell on screen.** A grid taller or wider than its scroll container sees the active
1013
+ cell inside its own viewport, so after landing the cell there the grid also scrolls the containers outside it (the
1014
+ host's box, the page) by the least amount that shows the cell — block and inline "nearest", honouring their
1015
+ `scroll-padding` (give a scroller with a sticky bar a matching `scroll-padding`). The reveal goes through a hidden
1016
+ marker under the root (`data-aqsp-reveal`), clipped to the root's box, so no container inside the grid moves; a
1017
+ pointer selection does not scroll (the pressed cell is on screen).
1018
+
1019
+ The demo's `/fit` route is the reference wiring.
1020
+
985
1021
  ## View geometry and zoom rules (`@aiquants/spreadsheet/view`)
986
1022
 
987
1023
  `@aiquants/spreadsheet/view` publishes the grid's fixed geometry, its defaults and its zoom ladder without loading the grid
@@ -1058,7 +1094,7 @@ importers with the exact same Excel-compatible semantics the grid uses. `enumera
1058
1094
  `onOperationRejected` op), and `EditRejection` (the `onEditRejected` context — see "Editing, Undo, and rejection") are exported for
1059
1095
  consumer-side annotations.
1060
1096
 
1061
- v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 44 values and 98 types in total — every named type an exported declaration reaches is exported too, so nothing has to be extracted with `Awaited<ReturnType<>>` / `Extract<>`; `src/index.spec.ts` pins it):
1097
+ v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 44 values and 99 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):
1062
1098
 
1063
1099
  - **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`),
1064
1100
  `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).
@@ -1178,6 +1214,7 @@ covers the `?lang=ja` path and the live switch; every other E2E spec runs the En
1178
1214
  | `/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 — and, since v0.9, the trailing freeze live by default: `frozenTrailingRows={1}` / `frozenTrailingColumns={1}` alongside the leading 1×1 (this nine-quadrant state is the ambient every `/styles` budget number is certified on) pin the sheet's grand-total row (the last row's `SUM` / `AVERAGE`, bold at the base font) to the bottom edge, with a `data-demo-trailing` checkbox to release it |
1179
1215
  | `/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 — plus the v0.9 trailing-freeze controls consumed by `tests/e2e/trailing-freeze.spec.ts`: trailing freeze toggles for 0 ↔ 2 last rows / columns (`data-demo-trailing-row-toggle` / `data-demo-trailing-col-toggle` — deliberately not the leading toggles' 0 ↔ 3), a row-axis deep jump to the last scroll row `rowCount − T − 1` (`data-demo-deep-jump-row`, reading the live toggle), and an overlap toggle (`data-demo-overlap-toggle` — 16 / 16 frozen rows and 8 / 8 frozen columns exceed the default E2E viewport on both axes) |
1180
1216
  | `/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 |
1217
+ | `/fit` | v0.17 — a host that fits the grid's box to `onContentSizeChange` (`tests/e2e/host-fit.spec.ts`): a read-only 60 × 6 table in the page's flow whose box shows every row so the page scrolls instead, with the headings switch (`data-demo-fit-headings`), the zoom (`data-demo-fit-zoom`), the all-rows fit through `autoFitColumns` (`data-demo-fit-autofit`) and the content-size readout (`data-demo-fit-size`); arrow keys keep the active cell in the window |
1181
1218
  | `/a11y` | Locale-aware custom `formatCellAnnouncement` / `formatSelectionAnnouncement` (English by default, Japanese under `?lang=ja`) mirrored into visible readouts, per-locale overridden accessible names (Escape advisement kept), the WCAG 2.1.2 escape route |
1182
1219
  | `/readonly` | The v0.10 read-only cells (`tests/e2e/readonly.spec.ts`): a 200 × 10 sheet whose adapter declares `isCellReadOnly` (one locked COLUMN plus scattered locked cells for mixed selections) alongside note / comment / link / validation reads — the whole-operation refusals live (edit entries, Delete, paste, cut, fill incl. the diagonal legs, annotation chords on empty cells), the content-aware viewers (the locked column carries a comment thread, a note and a hyperlink; their chords open viewer-mode popovers with the commit affordance disabled), a host-side tint on `[data-aqsp-readonly]` (the package ships no CSS), a refusal toast worded per `ReadOnlyRejection` (the three `editSession` fates told apart, the two-producer cut wording), and an `onPasteResolved` two-line log that sits beside the refusal toast so a read-only-refused paste shows BOTH callbacks |
1183
1220
  | `/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) |