@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.
@@ -53,7 +53,7 @@ import { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle
53
53
  import type { ClipChannel } from "./clipboard/clipPayload.ts"
54
54
  import type { PasteOptions } from "./clipboard/pasteSpecial.ts"
55
55
  import { type ClipInfo, type PasteOutcome, type PasteSheet, useClipboard } from "./clipboard/useClipboard.ts"
56
- import { type BorderPreset, type CommandRunner, useCommands } from "./commands/useCommands.ts"
56
+ import { type BorderPreset, type CommandRunner, type KeyboardCellRect, useCommands } from "./commands/useCommands.ts"
57
57
  import { type SinkSnapshot, useCellEditor } from "./editor/useCellEditor.ts"
58
58
  import type { RenderCellContext } from "./grid/CellView.tsx"
59
59
  import { GridBody } from "./grid/GridBody.tsx"
@@ -85,7 +85,7 @@ import { clampSelectionToGrid, reduceActiveCycle, reduceSelectionExtend, reduceS
85
85
  import { useAutoScroller } from "./selection/useAutoScroller.ts"
86
86
  import { type PointDragOwner, usePointDrag } from "./selection/usePointDrag.ts"
87
87
  import { useSelectionEngine } from "./selection/useSelectionEngine.ts"
88
- import { clampSpreadsheetZoom, SPREADSHEET_DEFAULT_COLUMN_WIDTH_PX, SPREADSHEET_MAX_OPERATION_CELLS, stepSpreadsheetZoom } from "./view.ts"
88
+ import { clampSpreadsheetZoom, SPREADSHEET_DEFAULT_COLUMN_WIDTH_PX, SPREADSHEET_MAX_OPERATION_CELLS, SPREADSHEET_SCROLLBAR_SIZE_PX, stepSpreadsheetZoom } from "./view.ts"
89
89
 
90
90
  export type { SelectionState } from "./selection/selectionState.ts"
91
91
 
@@ -103,12 +103,42 @@ export type { SelectionState } from "./selection/selectionState.ts"
103
103
  */
104
104
  export type ScrollToCellOptions = { alignY?: "top" | "bottom" | "center"; alignX?: "start" | "end" | "center"; offsetX?: number; offsetY?: number }
105
105
 
106
+ /**
107
+ * The size of a grid's content in CSS px of the page (v0.17): the column-letter and row-number bands (0 when `headings`
108
+ * is false), every column and row at its current size, and the two scroll bars the grid always reserves, all scaled by
109
+ * the zoom — the box a host gives the grid to show every column and row without scrolling.
110
+ * グリッドの中身の大きさ (ページの CSS px。v0.17)。列の記号と行の番号の帯 (`headings` が false なら 0)、今の大きさの全部の列と行、
111
+ * グリッドがいつも取る 2 本のスクロールバーを、どれも倍率で伸び縮みさせたもの。ホストがスクロールなしで全部の列と行を見せる箱の大きさ。
112
+ */
113
+ export type SpreadsheetContentSize = {
114
+ /** The width in CSS px. 幅 (CSS px)。 */
115
+ readonly width: number
116
+ /** The height in CSS px. 高さ (CSS px)。 */
117
+ readonly height: number
118
+ }
119
+
106
120
  /** Imperative surface (§6.9-8). / 命令 API (§6.9-8)。 */
107
121
  export type SpreadsheetHandle = {
108
122
  /** Row-height pair seam (§6.9-8). / 行高変更の対シーム。 */
109
123
  resizeRow: (row: number, heightPx: number) => void
110
124
  /** Column-width resize (§6.9-8). / 列幅変更。 */
111
125
  resizeColumn: (col: number, widthPx: number) => void
126
+ /**
127
+ * Fits each listed column to the widest text of ALL its rows (header rows included; v0.17), as Alt+Shift+= fits the
128
+ * active column over the rows in view: the same painted fonts, the 9 px padding and the 10,000 px bound, measured with
129
+ * the cells' base font. A hidden column keeps its width and so does an empty one. Refused through
130
+ * onOperationRejected("resize", cells, "limit") when the cells to measure exceed `maxOperationCells`. Works whatever
131
+ * `enableResizeUI` and `headings` say (an imperative path, like resizeColumn).
132
+ * 名指した列を、その列の**全部の行** (見出しの行も) で最も広い文字に合わせる (v0.17)。Alt+Shift+= が表示中の行で active 列を
133
+ * 合わせるのと同じ塗りの書体・余白 9 px・上限 10,000 px で、セルの基本の書体で測る。隠した列と空の列は幅を保つ。測るセルが
134
+ * `maxOperationCells` を超えると onOperationRejected("resize", セル, "limit") で拒む。`enableResizeUI` と `headings` に依らず
135
+ * 働く (resizeColumn と同じ命令の経路)。
136
+ *
137
+ * @param columns - The columns to fit (repeats count once). 合わせる列 (重複は 1 回)。
138
+ * @returns True when the fit ran (a hidden or empty column changes nothing); false when refused or when the grid cannot measure (`renderCell` is set, or the grid is not mounted). 走ったら true (隠した列・空の列は何も変えない)。拒まれたとき・測れないとき (`renderCell` がある、または未装着) は false。
139
+ * @throws {RangeError} When a column is not an integer between 0 and the column count − 1. 列が 0〜列数 − 1 の整数でない場合。
140
+ */
141
+ autoFitColumns: (columns: readonly number[]) => boolean
112
142
  /**
113
143
  * Programmatic viewport jump to a cell (delegates to the engine's scrollToCell — the
114
144
  * floor-and-pin jump API). Selection is unchanged; the deep grid stays reachable at any
@@ -448,6 +478,29 @@ export type SpreadsheetProps = {
448
478
  * 復元手段を失う (命令 API のみ)。
449
479
  */
450
480
  enableResizeUI?: boolean
481
+ /**
482
+ * v0.17 — Shows the column letters, the row numbers and the corner (Excel's "headings"; not `headerRows`), true by
483
+ * default. False draws none of the three and collapses their grid tracks to 0, so the cells start at the grid's
484
+ * top-left corner; the gestures that live only on them go with them (header presses, the corner's select-all, the
485
+ * resize strips and unhide markers), and their keyboard twins stay (Ctrl+Space, Shift+Space, Ctrl+A, Alt+Shift+Arrow,
486
+ * Alt+Shift+=). The bands are `aria-hidden`, so assistive technology meets the same grid. The root carries
487
+ * `data-aqsp-headings-hidden` while they are hidden. LIVE prop.
488
+ * v0.17 — 列の記号・行の番号・角 (Excel の「見出し」。`headerRows` とは別) を見せるか (既定 true)。false は 3 つを描かず、
489
+ * その格子の帯を 0 にしてセルを根の左上から始める。それらの上にだけある操作 (見出しの押下・角の全選択・境の帯と再表示の印) は
490
+ * 無くなり、キーの双子 (Ctrl+Space・Shift+Space・Ctrl+A・Alt+Shift+矢印・Alt+Shift+=) は残る。帯は `aria-hidden` なので、
491
+ * 支援技術から見えるグリッドは変わらない。隠している間、根は `data-aqsp-headings-hidden` を持つ。
492
+ */
493
+ headings?: boolean
494
+ /**
495
+ * v0.17 — Receives the size of the grid's content (`SpreadsheetContentSize`) after the first layout and again after
496
+ * every change of it: a track's size (a resize, an autofit, a hide or unhide, the imperative resizeRow / resizeColumn),
497
+ * the row or column count, the default sizes, the zoom or `headings`. An unchanged size is not repeated. A host uses it
498
+ * to size the grid's box to its content.
499
+ * v0.17 — グリッドの中身の大きさ (`SpreadsheetContentSize`) を、最初の配置の後と、それが変わるたびに受け取る (トラックの
500
+ * 大きさ — 境の操作・autofit・隠す・再表示・命令の resizeRow / resizeColumn —、行や列の数、既定の大きさ、倍率、`headings`)。
501
+ * 同じ大きさは繰り返さない。ホストはこれでグリッドの箱を中身に合わせる。
502
+ */
503
+ onContentSizeChange?: (size: SpreadsheetContentSize) => void
451
504
  /**
452
505
  * AT announcement text for the active cell; omit for silence (no built-in strings). `text`
453
506
  * is the RAW canonical getCellText; `displayText` is the value-format display string (the
@@ -837,6 +890,8 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
837
890
  maxOperationCells = SPREADSHEET_MAX_OPERATION_CELLS,
838
891
  enableFillHandle = true,
839
892
  enableResizeUI = true,
893
+ headings = true,
894
+ onContentSizeChange,
840
895
  formatCellAnnouncement,
841
896
  formatSelectionAnnouncement,
842
897
  formatCommandAnnouncement,
@@ -1064,6 +1119,8 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1064
1119
  }
1065
1120
  }, [requestZoom, zoomGestureEnabled])
1066
1121
  const bodyRef = useRef<HTMLDivElement | null>(null)
1122
+ const bodyStackRef = useRef<HTMLDivElement | null>(null)
1123
+ const revealRef = useRef<HTMLDivElement | null>(null)
1067
1124
  const handleRef = useRef<VirtualGridHandle | null>(null)
1068
1125
  const sinkRef = useRef<HTMLTextAreaElement | null>(null)
1069
1126
  const rowTrackRef = useRef<HTMLDivElement | null>(null)
@@ -1347,9 +1404,133 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1347
1404
  ensureDepsRef.current = { rowAxis, colAxis }
1348
1405
  ensureCellVisibleRef.current = createEnsureCellVisible({ handleRef, rowAxis, colAxis })
1349
1406
  }
1350
- const ensureCellVisible = useCallback((cell: CellCoord) => {
1351
- ensureCellVisibleRef.current?.(cell)
1352
- }, [])
1407
+ /**
1408
+ * A cell's rect in visual px relative to the body pane, read now through the three-way forward map (v0.9 §4.9 census
1409
+ * #11 — the keyboard-origin cellRect recipe, the onCellContextMenu twin): the one judgment site of this file, shared by
1410
+ * the keyboard cellRect and the page reveal.
1411
+ * セルの本体ペイン基準の視覚 px 矩形 (3 分岐順写像の判断時読み — v0.9 §4.9 census #11 の keyboard 起点 cellRect のレシピ、
1412
+ * onCellContextMenu と同式)。このファイル唯一の判断点で、keyboard の cellRect とページの追従が共有する。
1413
+ *
1414
+ * @param cell - The cell. セル。
1415
+ * @returns The rect in visual px. 視覚 px の矩形。
1416
+ */
1417
+ const cellRectOf = useCallback(
1418
+ (cell: CellCoord): KeyboardCellRect => {
1419
+ const handle = handleRef.current
1420
+ const position = handle === null ? { x: 0, y: 0 } : handle.getScrollPosition()
1421
+ // 帯件数と extent も同じ判断で読む (v0.9 計画 §4.9、census #11 — AxisForwardCtx はこの場で
1422
+ // インライン構築、§2.1 規則 1)。detached ハンドルは件数 0 / extent 0 の縮退 (末尾分岐不活性 = v0.8 恒等)
1423
+ const frozen = handle === null ? { rows: 0, cols: 0, trailingRows: 0, trailingCols: 0 } : handle.getFrozenSize()
1424
+ const viewport = handle === null ? { width: 0, height: 0 } : handle.getViewportSize()
1425
+ // 両軸とも番兵クランプ (detached の {-1, -1} を座標へ流さない)、ズーム境界で視覚 px へ
1426
+ const z = zoomRef.current
1427
+ return {
1428
+ // 行 / 列とも凍結パーティション 3 分岐順写像 (末尾は extent アンカー — v0.9 §2.1)。×zoom は写像後 (契約不変)
1429
+ top: rowToViewportY(rowAxis, cell.row, { scroll: Math.max(0, position.y), frozenCount: frozen.rows, trailingCount: frozen.trailingRows, extent: viewport.height }) * z,
1430
+ left: colToViewportX(colAxis, cell.col, { scroll: Math.max(0, position.x), frozenCount: frozen.cols, trailingCount: frozen.trailingCols, extent: viewport.width }) * z,
1431
+ width: colAxis.sizeOf(cell.col) * z,
1432
+ height: rowAxis.sizeOf(cell.row) * z,
1433
+ }
1434
+ },
1435
+ [rowAxis, colAxis],
1436
+ )
1437
+
1438
+ /**
1439
+ * Brings a cell the grid has just landed in its own viewport into view in the scroll containers outside the grid (a
1440
+ * host's box, the page), by the least scroll (block and inline "nearest", honouring their scroll-padding): a grid
1441
+ * taller or wider than its scroll container would otherwise keep the active cell off-screen, since the grid sees it
1442
+ * inside its own viewport (WCAG 2.4.11 / 2.4.12). The marker sits under the root, outside every container inside the
1443
+ * grid, and is clipped to the root's box, so only containers outside the grid move.
1444
+ * グリッドが自分の表示域の中へ着地させたセルを、グリッドの外のスクロールの容器 (ホストの箱・ページ) の中でも最小の量で見せる
1445
+ * 処理 (block・inline とも nearest、容器の scroll-padding に従う)。スクロールの容器より高い・広いグリッドでは、グリッドは自分の
1446
+ * 表示域の中にセルを見ているため、選んだセルが画面の外に残る (WCAG 2.4.11・2.4.12)。印は根の直下でグリッドの中のどの容器の
1447
+ * 外にも置き、根の箱に切り詰めるので、動くのはグリッドの外の容器だけ。
1448
+ *
1449
+ * @param cell - The cell, already in the grid's viewport. グリッドの表示域に着地済みのセル。
1450
+ */
1451
+ const revealCellInPage = useCallback(
1452
+ (cell: CellCoord): void => {
1453
+ const marker = revealRef.current
1454
+ const root = rootRef.current
1455
+ const stack = bodyStackRef.current
1456
+ if (marker === null || root === null || stack === null || handleRef.current === null) {
1457
+ return
1458
+ }
1459
+ const z = zoomRef.current
1460
+ const rect = cellRectOf(cell)
1461
+ // 本体ペインの原点は帯の分だけ根の左上から離れる (論理 px の offset を倍率で視覚 px へ)
1462
+ const top = stack.offsetTop * z + rect.top
1463
+ const left = stack.offsetLeft * z + rect.left
1464
+ // 根は overflow: hidden のスクロールの容器。印が根の箱からはみ出すと scrollIntoView が根自身を動かすので、根の箱に切り詰める
1465
+ const clippedTop = Math.max(0, top)
1466
+ const clippedLeft = Math.max(0, left)
1467
+ const clippedBottom = Math.min(root.clientHeight, top + rect.height)
1468
+ const clippedRight = Math.min(root.clientWidth, left + rect.width)
1469
+ if (clippedBottom <= clippedTop || clippedRight <= clippedLeft) {
1470
+ return
1471
+ }
1472
+ marker.style.top = `${clippedTop}px`
1473
+ marker.style.left = `${clippedLeft}px`
1474
+ marker.style.width = `${clippedRight - clippedLeft}px`
1475
+ marker.style.height = `${clippedBottom - clippedTop}px`
1476
+ // 配置された箱を持たない印 (display: none の中のグリッド) には見せる位置が無い
1477
+ const box = marker.getBoundingClientRect()
1478
+ if (box.width === 0 && box.height === 0) {
1479
+ return
1480
+ }
1481
+ marker.scrollIntoView({ block: "nearest", inline: "nearest" })
1482
+ },
1483
+ [cellRectOf],
1484
+ )
1485
+
1486
+ const ensureCellVisible = useCallback(
1487
+ (cell: CellCoord) => {
1488
+ ensureCellVisibleRef.current?.(cell)
1489
+ revealCellInPage(cell)
1490
+ },
1491
+ [revealCellInPage],
1492
+ )
1493
+
1494
+ // ---- 中身の大きさの知らせ (v0.17) ----
1495
+ const onContentSizeChangeRef = useRef(onContentSizeChange)
1496
+ onContentSizeChangeRef.current = onContentSizeChange
1497
+ const reportedContentSizeRef = useRef<SpreadsheetContentSize | null>(null)
1498
+ /**
1499
+ * Reports the content size to `onContentSizeChange` when it differs from the last one reported: the bands' tracks (the
1500
+ * body pane's offset in the zoom wrapper, so a host's band-size variables and `headings` count as laid out), every
1501
+ * track, and the two scroll bars the grid always reserves, all inside the zoom wrapper and so scaled by the zoom.
1502
+ * 中身の大きさを、前に知らせたものと違うときだけ `onContentSizeChange` へ知らせる処理。帯の格子の帯 (ズームの包みの中の本体
1503
+ * ペインの位置なので、ホストの帯の寸法の変数と `headings` が配置どおりに入る)、全部のトラック、グリッドがいつも取る 2 本の
1504
+ * スクロールバーで、どれもズームの包みの中にあるので倍率で伸び縮みする。
1505
+ */
1506
+ const reportContentSize = useCallback((): void => {
1507
+ const listener = onContentSizeChangeRef.current
1508
+ const stack = bodyStackRef.current
1509
+ if (listener === undefined || stack === null) {
1510
+ return
1511
+ }
1512
+ const z = zoomRef.current
1513
+ const size: SpreadsheetContentSize = {
1514
+ width: (stack.offsetLeft + colAxis.totalSize() + SPREADSHEET_SCROLLBAR_SIZE_PX) * z,
1515
+ height: (stack.offsetTop + rowAxis.totalSize() + SPREADSHEET_SCROLLBAR_SIZE_PX) * z,
1516
+ }
1517
+ const last = reportedContentSizeRef.current
1518
+ if (last !== null && last.width === size.width && last.height === size.height) {
1519
+ return
1520
+ }
1521
+ reportedContentSizeRef.current = size
1522
+ listener(size)
1523
+ }, [rowAxis, colAxis])
1524
+ // 描画のたびに測る (倍率・headings・行と列の数・既定の大きさの変化はどれも再描画を伴う)。知らせる先が外れたら次に付いた先へ
1525
+ // 今の大きさを改めて知らせ、同じ関数でない新しい知らせ先 (描画ごとに作るアロー関数) には値が変わるまで繰り返さない (同じ値の
1526
+ // 再送が知らせ先の状態更新と再描画を無限に回すため)
1527
+ useLayoutEffect(() => {
1528
+ if (onContentSizeChange === undefined) {
1529
+ reportedContentSizeRef.current = null
1530
+ return
1531
+ }
1532
+ reportContentSize()
1533
+ })
1353
1534
 
1354
1535
  // ---- 読み上げ (live region — §6.9-9) ----
1355
1536
  const announceTimerRef = useRef<number | null>(null)
@@ -1755,6 +1936,8 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1755
1936
  // 同期 repaint はエンジン再レンダー前の stale getFrozenSize — 分割線可視は
1756
1937
  // post-commit heal が commit 済み値で整合させる (§2.7 — スクロール介在なし)
1757
1938
  scheduleDividerHeal()
1939
+ // 寸法の変更は再描画を伴わない命令の経路なので、描画の後の知らせを待たずにここで知らせる
1940
+ reportContentSize()
1758
1941
  return
1759
1942
  }
1760
1943
  for (const entry of entries) {
@@ -1768,8 +1951,9 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1768
1951
  repaintOverlay()
1769
1952
  // 行側も同文 (H_F / H_T の分割線可視は commit 済み値で heal — §2.7)
1770
1953
  scheduleDividerHeal()
1954
+ reportContentSize()
1771
1955
  },
1772
- [rowAxis, colAxis, viewStore, repaintColHeaders, repaintHeaders, repaintOverlay, scheduleDividerHeal],
1956
+ [rowAxis, colAxis, viewStore, repaintColHeaders, repaintHeaders, repaintOverlay, scheduleDividerHeal, reportContentSize],
1773
1957
  )
1774
1958
 
1775
1959
  /** Pointer contextmenu relay to onCellContextMenu (§6.4.3-4 sole channel). / pointer 起点の横流し。 */
@@ -1864,9 +2048,10 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1864
2048
  })
1865
2049
 
1866
2050
  // ---- ヘッダー選択 (§6.8 — 列 / 行の転置共通機械) ----
2051
+ // 帯の上の機械は帯があるときだけ結線する (headings を切り替えると依存が変わり、新しい帯の要素へ結線し直す)
1867
2052
  const colHeaderMachine = useHeaderSelection({
1868
2053
  zoomRef,
1869
- enabled: !isEmpty,
2054
+ enabled: !isEmpty && headings,
1870
2055
  axis: "col",
1871
2056
  headerRef: colHeaderRef,
1872
2057
  mainAxis: colAxis,
@@ -1885,7 +2070,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1885
2070
  })
1886
2071
  const rowHeaderMachine = useHeaderSelection({
1887
2072
  zoomRef,
1888
- enabled: !isEmpty,
2073
+ enabled: !isEmpty && headings,
1889
2074
  axis: "row",
1890
2075
  headerRef: rowHeaderRef,
1891
2076
  mainAxis: rowAxis,
@@ -1906,9 +2091,10 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
1906
2091
  // ---- リサイズ / unhide 機械 (§7 PT-1) + fill 機械 (§8 PT-2) ----
1907
2092
  const headerResizeMachine = useHeaderResize({
1908
2093
  zoomRef,
1909
- enabled: !isEmpty && enableResizeUI,
2094
+ enabled: !isEmpty && enableResizeUI && headings,
1910
2095
  colBandRef: colHeaderRef,
1911
2096
  rowBandRef: rowHeaderRef,
2097
+ fontSourceRef: bodyRef,
1912
2098
  colAxis,
1913
2099
  rowAxis,
1914
2100
  viewStore,
@@ -2144,26 +2330,12 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2144
2330
 
2145
2331
  // ---- コマンド実行 (v0.8 §4.1.13 / ADR-17) ----
2146
2332
  /** The active cell's viewport rect in visual px via the three-way forward map (v0.9 §4.9 census #11 — the keyboard-origin cellRect recipe, onCellContextMenu twin) for onPasteSpecialRequest. / active セルの視覚 px 矩形 — 3 分岐順写像 (v0.9 §4.9 census #11。keyboard 起点 cellRect のレシピ、onCellContextMenu と同式)。 */
2147
- const keyboardCellRect = useCallback(() => {
2333
+ const keyboardCellRect = useCallback((): KeyboardCellRect => {
2148
2334
  const active = selectionRef.current.active
2149
2335
  // 画面外座標へメニューを出さない (§6.6.1) — 可視化してからハンドルの同期読み (§7-4)
2150
2336
  ensureCellVisible(active)
2151
- const handle = handleRef.current
2152
- const position = handle === null ? { x: 0, y: 0 } : handle.getScrollPosition()
2153
- // 帯件数と extent も同じ判断で読む (v0.9 計画 §4.9、census #11 — AxisForwardCtx はこの場で
2154
- // インライン構築、§2.1 規則 1)。detached ハンドルは件数 0 / extent 0 の縮退 (末尾分岐不活性 = v0.8 恒等)
2155
- const frozen = handle === null ? { rows: 0, cols: 0, trailingRows: 0, trailingCols: 0 } : handle.getFrozenSize()
2156
- const viewport = handle === null ? { width: 0, height: 0 } : handle.getViewportSize()
2157
- // 両軸とも番兵クランプ (detached の {-1, -1} を座標へ流さない)、ズーム境界で視覚 px へ
2158
- const z = zoomRef.current
2159
- return {
2160
- // 行 / 列とも凍結パーティション 3 分岐順写像 (末尾は extent アンカー — v0.9 §2.1)。×zoom は写像後 (契約不変)
2161
- top: rowToViewportY(rowAxis, active.row, { scroll: Math.max(0, position.y), frozenCount: frozen.rows, trailingCount: frozen.trailingRows, extent: viewport.height }) * z,
2162
- left: colToViewportX(colAxis, active.col, { scroll: Math.max(0, position.x), frozenCount: frozen.cols, trailingCount: frozen.trailingCols, extent: viewport.width }) * z,
2163
- width: colAxis.sizeOf(active.col) * z,
2164
- height: rowAxis.sizeOf(active.row) * z,
2165
- }
2166
- }, [rowAxis, colAxis, ensureCellVisible])
2337
+ return cellRectOf(active)
2338
+ }, [ensureCellVisible, cellRectOf])
2167
2339
  const commands = useCommands({
2168
2340
  adapter,
2169
2341
  // キーボードの寸法の変更は帯のリサイズ UI と同じ可否 (enableResizeUI が false なら利用者は寸法を変えない)
@@ -2725,6 +2897,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2725
2897
  repaintOverlay()
2726
2898
  // 分割線可視は commit 済み getFrozenSize の post-commit heal で整合 (§2.7)
2727
2899
  scheduleDividerHeal()
2900
+ reportContentSize()
2728
2901
  },
2729
2902
  resizeColumn: (col: number, widthPx: number) => {
2730
2903
  // 対呼び出し (§3.8): colAxis (SSOT) を先に更新し、グリッドの幅木へ通知する。
@@ -2739,6 +2912,15 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2739
2912
  repaintOverlay()
2740
2913
  // 分割線可視は commit 済み getFrozenSize の post-commit heal で整合 (§2.7)
2741
2914
  scheduleDividerHeal()
2915
+ reportContentSize()
2916
+ },
2917
+ autoFitColumns: (columns: readonly number[]) => {
2918
+ for (const col of columns) {
2919
+ if (!Number.isInteger(col) || col < 0 || col >= colAxis.count) {
2920
+ throw new RangeError(`[spreadsheet] autoFitColumns: each column must be a whole number from 0 to ${colAxis.count - 1} (got ${String(col)})`)
2921
+ }
2922
+ }
2923
+ return headerResizeMachine.autofitColumnsInAllRows(columns)
2742
2924
  },
2743
2925
  scrollToCell: (cell: CellCoord, options?: ScrollToCellOptions) => {
2744
2926
  // エンジンのセル狙いジャンプへ素通し (§7-4 の真実。選択は変えない — 純 viewport 移動)。
@@ -2773,7 +2955,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2773
2955
  openPopoverRef.current(kind)
2774
2956
  },
2775
2957
  }),
2776
- [rowAxis, colAxis, viewStore, repaintColHeaders, repaintHeaders, repaintOverlay, scheduleDividerHeal, focusSink, isEditing, rearmSink, adapter, paintedFormatOf, startEdit, requireCommands],
2958
+ [rowAxis, colAxis, viewStore, repaintColHeaders, repaintHeaders, repaintOverlay, scheduleDividerHeal, reportContentSize, headerResizeMachine, focusSink, isEditing, rearmSink, adapter, paintedFormatOf, startEdit, requireCommands],
2777
2959
  )
2778
2960
 
2779
2961
  // ---- 空グリッド分岐 (§6.9-1: フックの後・結線の前) ----
@@ -2791,75 +2973,79 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2791
2973
  return (
2792
2974
  // biome-ignore lint/a11y/useSemanticElements: fieldset はフォーム意味論であり不適。role="group" は Escape 脱出の着地点に現在地名を与えるための選択 (§6.9-7 / §10-4)
2793
2975
  // biome-ignore lint/a11y/noNoninteractiveTabindex: tabIndex=0 は WCAG 2.1.2 の脱出着地点 + グリッド再進入口 (§10-4) — 意図的な tab stop
2794
- <div ref={rootRef} className={className ? `aqsp-root ${className}` : "aqsp-root"} role="group" {...rootName} tabIndex={0} onFocus={onRootFocus} onBlur={onRootBlur} data-aqsp-root="" data-testid={testId}>
2976
+ <div ref={rootRef} className={className ? `aqsp-root ${className}` : "aqsp-root"} role="group" {...rootName} tabIndex={0} onFocus={onRootFocus} onBlur={onRootBlur} data-aqsp-root="" data-aqsp-headings-hidden={headings ? undefined : ""} data-testid={testId}>
2795
2977
  {/* ズームラッパー (v0.3): 視覚は transform スケール、内側レイアウトは 100%/z の論理 px —
2796
2978
  virtualscroll の実測 (ResizeObserver / clientHeight) は論理値のまま。z=1 は無スタイル
2797
2979
  (v0.2 と byte 同一の DOM 幾何 — 既存全ゲートの不変性) */}
2798
2980
  <div className="aqsp-zoom" data-aqsp-zoom={zoomValue} onPointerDown={onZoomPointerDown} style={zoomValue === 1 ? undefined : { width: `${100 / zoomValue}%`, height: `${100 / zoomValue}%`, transform: `scale(${zoomValue})` }}>
2799
- <div
2800
- className="aqsp-corner"
2801
- aria-hidden="true"
2802
- data-aqsp-corner=""
2803
- onPointerDown={(event) => {
2804
- // mouse 限定 (§6.4.1 の表ヘッダー「mouse のみ」— エンジン / ヘッダー機械と同一ゲート)
2805
- if (event.pointerType !== "mouse") {
2806
- return
2807
- }
2808
- // 非主ボタンも既定のフォーカス移動だけは止める (全押下面共通 — R2 是正)
2809
- event.preventDefault()
2810
- if (event.button !== 0) {
2811
- return
2812
- }
2813
- // 参照ポインティング中 (v0.8 §4.2.13): コーナー押下は consume + シンクへ焦点 (全選択も確定もしない)
2814
- if (isPointing()) {
2815
- focusSink()
2816
- return
2817
- }
2818
- // 編集中の requestCommit ゲート (§6.8-3 — コーナーもフォーカス不動で focusout 不発)
2819
- if (isEditing() && !requestCommit()) {
2820
- focusSink()
2821
- return
2822
- }
2823
- focusSink()
2824
- // コーナーの全セル選択は単一構築 state の setSelection 1 回 (過渡状態なし)。active = A1
2825
- setSelection({
2826
- ranges: [makeRange({ row: 0, col: 0 }, { row: adapter.rowCount - 1, col: adapter.colCount - 1 })],
2827
- anchor: { row: 0, col: 0 },
2828
- active: { row: 0, col: 0 },
2829
- })
2830
- }}
2831
- />
2832
- <ColumnHeader
2833
- viewStore={viewStore}
2834
- colAxis={colAxis}
2835
- frozenCols={frozenColumns}
2836
- frozenTrailingCols={frozenTrailingColumns}
2837
- handleRef={handleRef}
2838
- bandRef={colHeaderRef}
2839
- colTrackRef={colTrackRef}
2840
- windowOriginXRef={windowOriginXRef}
2841
- repaintColHeaders={repaintColHeaders}
2842
- selectionRanges={selection.ranges}
2843
- enableResizeUI={enableResizeUI}
2844
- dividerLeadingRef={dividerXLeadingRef}
2845
- dividerTrailingRef={dividerXTrailingRef}
2846
- />
2847
- <RowHeader
2848
- viewStore={viewStore}
2849
- rowAxis={rowAxis}
2850
- handleRef={handleRef}
2851
- bandRef={rowHeaderRef}
2852
- selectionRanges={selection.ranges}
2853
- rowTrackRef={rowTrackRef}
2854
- windowOriginYRef={windowOriginYRef}
2855
- repaintHeaders={repaintHeaders}
2856
- enableResizeUI={enableResizeUI}
2857
- frozenRows={frozenRows}
2858
- frozenTrailingRows={frozenTrailingRows}
2859
- dividerLeadingRef={dividerYLeadingRef}
2860
- dividerTrailingRef={dividerYTrailingRef}
2861
- />
2862
- <div className="aqsp-body-stack" data-aqsp-body-stack="">
2981
+ {headings ? (
2982
+ <>
2983
+ <div
2984
+ className="aqsp-corner"
2985
+ aria-hidden="true"
2986
+ data-aqsp-corner=""
2987
+ onPointerDown={(event) => {
2988
+ // mouse 限定 (§6.4.1 の表ヘッダー「mouse のみ」— エンジン / ヘッダー機械と同一ゲート)
2989
+ if (event.pointerType !== "mouse") {
2990
+ return
2991
+ }
2992
+ // 非主ボタンも既定のフォーカス移動だけは止める (全押下面共通 — R2 是正)
2993
+ event.preventDefault()
2994
+ if (event.button !== 0) {
2995
+ return
2996
+ }
2997
+ // 参照ポインティング中 (v0.8 §4.2.13): コーナー押下は consume + シンクへ焦点 (全選択も確定もしない)
2998
+ if (isPointing()) {
2999
+ focusSink()
3000
+ return
3001
+ }
3002
+ // 編集中の requestCommit ゲート (§6.8-3 — コーナーもフォーカス不動で focusout 不発)
3003
+ if (isEditing() && !requestCommit()) {
3004
+ focusSink()
3005
+ return
3006
+ }
3007
+ focusSink()
3008
+ // コーナーの全セル選択は単一構築 state の setSelection 1 回 (過渡状態なし)。active = A1
3009
+ setSelection({
3010
+ ranges: [makeRange({ row: 0, col: 0 }, { row: adapter.rowCount - 1, col: adapter.colCount - 1 })],
3011
+ anchor: { row: 0, col: 0 },
3012
+ active: { row: 0, col: 0 },
3013
+ })
3014
+ }}
3015
+ />
3016
+ <ColumnHeader
3017
+ viewStore={viewStore}
3018
+ colAxis={colAxis}
3019
+ frozenCols={frozenColumns}
3020
+ frozenTrailingCols={frozenTrailingColumns}
3021
+ handleRef={handleRef}
3022
+ bandRef={colHeaderRef}
3023
+ colTrackRef={colTrackRef}
3024
+ windowOriginXRef={windowOriginXRef}
3025
+ repaintColHeaders={repaintColHeaders}
3026
+ selectionRanges={selection.ranges}
3027
+ enableResizeUI={enableResizeUI}
3028
+ dividerLeadingRef={dividerXLeadingRef}
3029
+ dividerTrailingRef={dividerXTrailingRef}
3030
+ />
3031
+ <RowHeader
3032
+ viewStore={viewStore}
3033
+ rowAxis={rowAxis}
3034
+ handleRef={handleRef}
3035
+ bandRef={rowHeaderRef}
3036
+ selectionRanges={selection.ranges}
3037
+ rowTrackRef={rowTrackRef}
3038
+ windowOriginYRef={windowOriginYRef}
3039
+ repaintHeaders={repaintHeaders}
3040
+ enableResizeUI={enableResizeUI}
3041
+ frozenRows={frozenRows}
3042
+ frozenTrailingRows={frozenTrailingRows}
3043
+ dividerLeadingRef={dividerYLeadingRef}
3044
+ dividerTrailingRef={dividerYTrailingRef}
3045
+ />
3046
+ </>
3047
+ ) : null}
3048
+ <div ref={bodyStackRef} className="aqsp-body-stack" data-aqsp-body-stack="">
2863
3049
  <GridBody
2864
3050
  adapter={adapter}
2865
3051
  rowAxis={rowAxis}
@@ -2934,6 +3120,8 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2934
3120
  </div>
2935
3121
  {hasAnnouncement ? <div ref={liveRegionRef} className="aqsp-live-region" aria-live="polite" data-aqsp-live-region="" /> : null}
2936
3122
  </div>
3123
+ {/* キーの移動で選んだセルをページの中で見せる印 (位置は revealCellInPage が書く)。ズームの包みの外の根の直下に置き、グリッドの中の容器を動かさない */}
3124
+ <div ref={revealRef} className="aqsp-reveal" aria-hidden="true" data-aqsp-reveal="" />
2937
3125
  </div>
2938
3126
  )
2939
3127
  })