@aiquants/spreadsheet 0.15.0 → 0.16.1

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.
@@ -28,6 +28,14 @@
28
28
  * helper (ADR-31 — no package CSS; capability absent ⇒ O(1) false ⇒ keys absent ⇒ the DOM
29
29
  * stays byte-identical).
30
30
  *
31
+ * Accessibility structure (v0.16): the body wrapper is the ONE `role="grid"` element (row / column counts, its name
32
+ * from `labelledBy` or the built-in root label, `aria-readonly` from `readOnly`; no `aria-multiselectable`, since no
33
+ * cell carries `aria-selected` — the live region announces the selection), so the frozen leading,
34
+ * scrolling and frozen trailing panes VirtualGrid draws inside it are all owned by it; the scrolling pane's content
35
+ * element is the body `rowgroup`, the cells of the first `headerRows` rows are `columnheader` (`data-aqsp-header-row`),
36
+ * and both scroll bars are declared pointer-only (`enableArrowButtonTabStops: false` — the grid owns keyboard
37
+ * scrolling), so no `scrollbar` / `button` becomes a child of the grid.
38
+ *
31
39
  * UI chrome locale: the resolved `locale` and the value-stable engine subset `scrollLabels` are
32
40
  * forwarded to VirtualGrid, which localizes the embedded vertical bar, the horizontal bar and the
33
41
  * degenerate-band "No items" — no lang attribute, no new data-* hook.
@@ -55,6 +63,13 @@
55
63
  * `aria-readonly="true"` + `data-aqsp-readonly=""` を `isReadOnlyCell` ヘルパ経由で条件スタンプ
56
64
  * する (ADR-31 — パッケージ CSS なし。能力未宣言 = O(1) false = キー不在 = DOM バイト同一)。
57
65
  *
66
+ * アクセシビリティの構造 (v0.16): 本体の包みが唯一の `role="grid"` (行数 / 列数・`labelledBy` か組み込みのルートの名前による
67
+ * 名前・`readOnly` からの `aria-readonly`。セルが `aria-selected` を持たないので `aria-multiselectable` は付けず、選択は
68
+ * live region が読み上げる) で、VirtualGrid がその中に描く先頭の凍結・スクロール・末尾の凍結の
69
+ * ペインを全て持つ。スクロールのペインの中身の要素は本体の `rowgroup`、先頭 `headerRows` 行のセルは `columnheader`
70
+ * (`data-aqsp-header-row`)、両方のスクロールバーはポインタ専用 (`enableArrowButtonTabStops: false` — キーボードのスクロールは
71
+ * グリッドが持つ) と宣言し、`scrollbar` / `button` をグリッドの子にしない。
72
+ *
58
73
  * UI クロームロケール: 解決済み `locale` と値安定なエンジン部分集合 `scrollLabels` を VirtualGrid へ
59
74
  * 素通しし、埋込み縦バー・横バー・退化帯の "No items" を現地化する (lang 属性・新規 data-* フックなし)。
60
75
  */
@@ -83,6 +98,15 @@ const ALIGN_ONLY_STYLES: Readonly<Record<CellAlign, CellStyle>> = Object.freeze(
83
98
  right: Object.freeze({ justifyContent: alignToJustify("right") }),
84
99
  })
85
100
 
101
+ /**
102
+ * The scrolling pane's content element is the row group of the body rows; the `role="grid"` element is the body wrapper,
103
+ * which contains the frozen leading pane, the scrolling pane and the frozen trailing pane, so every row has the grid as
104
+ * an ancestor.
105
+ * スクロールするペインの中身の要素は本体の行の行グループ。`role="grid"` は本体の包みが持ち、先頭の凍結のペイン・スクロールの
106
+ * ペイン・末尾の凍結のペインを全て含むので、どの行もグリッドを祖先に持つ。
107
+ */
108
+ const BODY_ROWGROUP_PROPS = Object.freeze({ role: "rowgroup" }) as { readonly role: "rowgroup" }
109
+
86
110
  /** Whole-Map clearing threshold of the canonical-JSON style intern (the CF memo's bound). / 正準 JSON style intern の全消去しきい値 (CF memo と同じ上限)。 */
87
111
  export const STYLE_INTERN_LIMIT = 4096
88
112
 
@@ -287,6 +311,14 @@ type GridBodyProps = {
287
311
  frozenTrailingColumns: number
288
312
  /** Trailing frozen rows (v0.9 — VirtualGrid frozenTrailingRows engine-validated pass-through; the row twin). / 末尾凍結行数 (v0.9 — frozenTrailingRows のエンジン検証素通し。行双子)。 */
289
313
  frozenTrailingRows: number
314
+ /** Leading rows whose cells are column headers (validated by the root against frozenRows). / セルが列の見出しになる先頭の行数 (root が frozenRows と照らして検証済み)。 */
315
+ headerRows: number
316
+ /** The id naming the grid (`aria-labelledby` of the `role="grid"` element), or undefined. / グリッドに名前を与える id (`role="grid"` の要素の `aria-labelledby`)。無ければ undefined。 */
317
+ labelledBy: string | undefined
318
+ /** The grid's own name (`aria-label`) when no `labelledBy` names it, else undefined. / `labelledBy` が無いときのグリッド自身の名前 (`aria-label`)。あれば undefined。 */
319
+ gridLabel: string | undefined
320
+ /** Whole-grid read-only (`aria-readonly` of the `role="grid"` element). / グリッド全体の読み取り専用 (`role="grid"` の要素の `aria-readonly`)。 */
321
+ readOnly: boolean
290
322
  renderCell?: (ctx: RenderCellContext) => ReactNode
291
323
  /** 2D scroll notification from the grid handle (the truth — §7-4). / グリッドハンドル発の 2 軸スクロール通知 (§7-4 の真実側)。 */
292
324
  onScroll: (position: { x: number; y: number }) => void
@@ -382,6 +414,10 @@ const GridBodyInner = ({
382
414
  frozenRows,
383
415
  frozenTrailingColumns,
384
416
  frozenTrailingRows,
417
+ headerRows,
418
+ labelledBy,
419
+ gridLabel,
420
+ readOnly,
385
421
  renderCell,
386
422
  onScroll,
387
423
  onRangeChange,
@@ -424,14 +460,14 @@ const GridBodyInner = ({
424
460
  const getRowHeight = useCallback((row: number) => rowAxis.sizeOf(row), [rowAxis])
425
461
  const getColWidth = useCallback((col: number) => colAxis.sizeOf(col), [colAxis])
426
462
  const getCell = useCallback((row: number, col: number) => adapter.getCellText(row, col), [adapter])
427
- // ARIA コンテナ属性 (identity は行列数にのみ追随)。型は行側と同型の狭い contentProps
428
- const contentProps = useMemo(() => ({ role: "grid", "aria-rowcount": adapter.rowCount, "aria-colcount": adapter.colCount, "aria-multiselectable": true }) as const, [adapter.rowCount, adapter.colCount])
429
463
  // 挙動オプション: 行木は identity 変化で再構築 (軸再構築追随)、列幅木も対称 + defaultColWidth
430
464
  // (§7-8 — サンプリング省略の Fenwick baseValue)。pointerDragInputs はペン / タッチのみ
431
465
  // (マウスドラッグは選択エンジンの所有)
432
466
  const behaviorOptions = useMemo(() => ({ enableKeyboardNavigation: false, pointerDragInputs: ["pen", "touch"] as const, resetOnGetItemHeightChange: true, resetOnGetColWidthChange: true, defaultColWidth: defaultColumnWidth }), [defaultColumnWidth])
433
467
  // タップサークル素通し (v0.10 §2.1): 未指定は undefined キー同乗 = エンジン宣言の不在信号
434
- const scrollBarOptions = useMemo(() => ({ width: SPREADSHEET_SCROLLBAR_SIZE_PX, tapScrollCircleOptions }), [tapScrollCircleOptions])
468
+ // 矢印の Tab の止まり先は false: キーボードのスクロールはグリッド自身 (シンク) が持つので、バーはネイティブのスクロールバーと同じ
469
+ // ポインタ専用の部品になり (aria-hidden)、role="grid" の中に許されない子 (scrollbar・button) を作らない
470
+ const scrollBarOptions = useMemo(() => ({ width: SPREADSHEET_SCROLLBAR_SIZE_PX, tapScrollCircleOptions, enableArrowButtonTabStops: false }), [tapScrollCircleOptions])
435
471
  // 表示値書式の掛け金 (getCellProps が書き、同じセルの children が直後に消費 — 番兵は届かない座標)
436
472
  const displayLatchRef = useRef<DisplayLatch>({ row: -1, col: -1, value: undefined })
437
473
  /**
@@ -446,9 +482,10 @@ const GridBodyInner = ({
446
482
  */
447
483
  const getCellProps = useCallback(
448
484
  (row: number, col: number) => {
485
+ const isHeader = row < headerRows
449
486
  const base = {
450
487
  // aria-rowindex は行ラッパーと重複出力 (合法な上位集合) — セル単独が AT に読まれた際の行文脈補助 (§10-1)
451
- role: "gridcell",
488
+ role: isHeader ? "columnheader" : "gridcell",
452
489
  "aria-rowindex": row + 1,
453
490
  "aria-colindex": col + 1,
454
491
  "data-aqsp-cell": "",
@@ -460,6 +497,7 @@ const GridBodyInner = ({
460
497
  // 単一居住 — layer-2 census)。未宣言 = O(1) false = キー不在 = v0.9 と DOM バイト同一、
461
498
  // 宣言ホストは塗りセル毎 1 述語読み (ADR-31 の登記コスト)
462
499
  ...(isReadOnlyCell(adapter, row, col) ? { "aria-readonly": "true", "data-aqsp-readonly": "" } : {}),
500
+ ...(isHeader ? { "data-aqsp-header-row": "" } : {}),
463
501
  }
464
502
  // v0.12 ホスト属性の継ぎ目: resolveCellAttributes が予約キー (data-aqsp* / data-row /
465
503
  // data-col) と非 data-* を fail-fast 済みなのでパッケージ属性と衝突しない。未宣言は
@@ -477,7 +515,7 @@ const GridBodyInner = ({
477
515
  }
478
516
  return (rich.style === null ? { ...stamped, ...rich.attrs } : { ...stamped, ...rich.attrs, style: rich.style }) as HTMLAttributes<HTMLDivElement>
479
517
  },
480
- [adapter, rowAxis, colAxis, cf],
518
+ [adapter, rowAxis, colAxis, cf, headerRows],
481
519
  )
482
520
  /** Row attribute surface — carries the engine event gate (data-aqsp-cells §6.4.3-1). / 行属性面 — エンジンのイベントゲート (data-aqsp-cells) を運ぶ。 */
483
521
  const getRowProps = useCallback(
@@ -518,11 +556,18 @@ const GridBodyInner = ({
518
556
  )
519
557
 
520
558
  return (
559
+ // biome-ignore lint/a11y/useSemanticElements: <table> を描けない — 行は VirtualGrid が 3 つのペインに仮想化して描き、表の構造要素の入れ子を作れないため、ARIA の grid で全ての行を包む
521
560
  <div
522
561
  ref={bodyRef}
523
562
  className="aqsp-body"
524
563
  data-aqsp-body=""
525
564
  data-testid={testId ? `${testId}-body` : undefined}
565
+ role="grid"
566
+ aria-rowcount={adapter.rowCount}
567
+ aria-colcount={adapter.colCount}
568
+ aria-label={gridLabel}
569
+ aria-labelledby={labelledBy}
570
+ aria-readonly={readOnly ? "true" : undefined}
526
571
  onPointerDownCapture={(event) => {
527
572
  // バー帯の統合ゲート (§6.8-3 — v0.4 でスクロールバー 2 本ともグリッド内在化):
528
573
  // セル領域 (data-aqsp-cells) 由来は選択エンジンの所有 — 素通し。それ以外 (縦バー /
@@ -574,7 +619,7 @@ const GridBodyInner = ({
574
619
  getCell={getCell}
575
620
  getCellProps={getCellProps}
576
621
  getRowProps={getRowProps}
577
- contentProps={contentProps}
622
+ contentProps={BODY_ROWGROUP_PROPS}
578
623
  behaviorOptions={behaviorOptions}
579
624
  scrollBarOptions={scrollBarOptions}
580
625
  overscanRows={overscanRows}
@@ -55,6 +55,40 @@ export const MAX_TRACK_SIZE_PX = 10_000
55
55
  */
56
56
  export const ROW_FANOUT_DIVISOR = 10
57
57
 
58
+ /**
59
+ * The ratio of one keyboard resize step: the zoom ladder's 2^(1/4), so four presses double a track and four halve it,
60
+ * at every size.
61
+ * キーボードの寸法の 1 段の比。拡大率の段と同じ 2^(1/4) で、どの寸法でも 4 回で 2 倍、4 回で半分になる。
62
+ */
63
+ const KEYBOARD_RESIZE_RATIO = 2 ** (1 / 4)
64
+
65
+ /**
66
+ * The size one keyboard step gives a track (increaseColumnWidth / decreaseColumnWidth / increaseRowHeight /
67
+ * decreaseRowHeight): `size × 2^(1/4)` up and `size / 2^(1/4)` down, rounded to whole px, moving at least 1 px, never
68
+ * below 1 px (a step never hides a track) and never above MAX_TRACK_SIZE_PX. A track already at a bound keeps its size:
69
+ * a hidden track (0 px) stays hidden in both directions (only unhide restores it), and growing never shrinks a track a
70
+ * host made wider than MAX_TRACK_SIZE_PX.
71
+ * キーボードの 1 段がトラックに与える寸法。上は `size × 2^(1/4)`、下は `size / 2^(1/4)` を整数 px に丸め、少なくとも 1 px 動き、
72
+ * 1 px を下回らず (1 段でトラックを隠さない)、MAX_TRACK_SIZE_PX を超えない。端にあるトラックは寸法を保つ: 隠れたトラック (0 px)
73
+ * はどちらの向きでも隠れたまま (戻すのは再表示だけ)、ホストが MAX_TRACK_SIZE_PX より広くしたトラックを大きくする向きで縮めない。
74
+ *
75
+ * @param size - The track's current size in px. トラックの今の寸法 (px)。
76
+ * @param direction - 1 to grow, -1 to shrink. 大きくするなら 1、小さくするなら -1。
77
+ * @returns The new size in px. 新しい寸法 (px)。
78
+ */
79
+ export const keyboardStepSize = (size: number, direction: 1 | -1): number => {
80
+ if (size <= 0) {
81
+ return size
82
+ }
83
+ if (direction > 0) {
84
+ if (size >= MAX_TRACK_SIZE_PX) {
85
+ return size
86
+ }
87
+ return Math.min(MAX_TRACK_SIZE_PX, Math.max(size + 1, Math.round(size * KEYBOARD_RESIZE_RATIO)))
88
+ }
89
+ return Math.max(1, Math.min(size - 1, Math.round(size / KEYBOARD_RESIZE_RATIO)))
90
+ }
91
+
58
92
  /** One track-size entry of a resize batch (size in logical px, 0 = hidden). / リサイズバッチの 1 entry (論理 px、0 = 非表示)。 */
59
93
  export type ResizeEntry = { index: number; size: number }
60
94
 
@@ -63,7 +63,7 @@ import type { ViewStore } from "../model/viewStore.ts"
63
63
  import type { ResizeGuide } from "../overlay/SelectionOverlay.tsx"
64
64
  import type { SelectionState } from "../selection/selectionState.ts"
65
65
  import type { SelectionEngine } from "../selection/useSelectionEngine.ts"
66
- import { gateResizeEntries, type ResizeEntry, ROW_FANOUT_DIVISOR, ROW_HIDDEN_LIMIT } from "./resizeGates.ts"
66
+ import { gateResizeEntries, keyboardStepSize, type ResizeEntry, ROW_FANOUT_DIVISOR, ROW_HIDDEN_LIMIT } from "./resizeGates.ts"
67
67
 
68
68
  /** Autofit horizontal padding: cell padding 4+4 + right border 1 (px). / autofit の水平パディング (セル padding 4+4 + 右罫 1)。 */
69
69
  const AUTOFIT_PADDING = 9
@@ -97,6 +97,8 @@ type UseHeaderResizeParams = {
97
97
  adapter: SpreadsheetDataAdapter
98
98
  /** Autofit adoption condition: renderCell unspecified (custom DOM defeats measureText — §10 deviation). / autofit 採用条件 (renderCell 未指定)。 */
99
99
  autofitEnabled: boolean
100
+ /** Leading header rows (`headerRows`): autofit measures their cells in the stylesheet's header-row weight. / 先頭の見出しの行数 (`headerRows`)。autofit はそのセルをスタイルシートの見出し行の太さで測る。 */
101
+ headerRows: number
100
102
  defaultRowHeight: number
101
103
  defaultColumnWidth: number
102
104
  maxOperationCells: number
@@ -125,7 +127,7 @@ type UseHeaderResizeParams = {
125
127
  * cellPaint と同式) / フォント族を重ねる)。書式 object 毎に intern (塗り intern が書式毎に 1 object を
126
128
  * 保つので、異なりフォント毎に 1 文字列)。
127
129
  */
128
- const fontKeyOf = (painted: CellFormat, fontKeys: WeakMap<CellFormat, string>, bandStyle: CSSStyleDeclaration): string => {
130
+ const fontKeyOf = (painted: CellFormat, fontKeys: WeakMap<CellFormat, string>, bandStyle: Pick<CSSStyleDeclaration, "fontStyle" | "fontWeight" | "fontSize" | "fontFamily">): string => {
129
131
  const cached = fontKeys.get(painted)
130
132
  if (cached !== undefined) {
131
133
  return cached
@@ -169,10 +171,26 @@ type ResizeGesture = {
169
171
  const guidePositionOf = (gesture: ResizeGesture, axisModel: AxisModel, pendingSize: number): number => (gesture.anchor === "trailing" ? axisModel.offsetOf(gesture.index) + gesture.initialSize - pendingSize : axisModel.offsetOf(gesture.index) + pendingSize)
170
172
 
171
173
  /**
172
- * Wires the resize/unhide machine onto both header bands; returns the §7.4 composite surface.
173
- * 両ヘッダー帯へリサイズ / unhide 機械を結線し、§7.4 の合成面を返す。
174
+ * The resize machine's surface: the §7.4 drag surface plus the keyboard twins of the strips, which resize through the
175
+ * same fan-out, gates and batch seam as a strip release. Both return whether the command ran: false only after a
176
+ * refusal through onOperationRejected (and for autofit while renderCell is set); a track already at a bound, an empty
177
+ * column or nothing to measure is a run that changes nothing (true).
178
+ * リサイズ機械の面。§7.4 のドラッグ面に、ストリップのキーボードの双子を加えたもの (ストリップの放しと同じファンアウト・
179
+ * ゲート・バッチの継ぎ目で寸法を変える)。どちらもコマンドが走ったかを返す: false は onOperationRejected で拒まれたとき
180
+ * (と renderCell がある間の autofit) だけで、端にあるトラック・全空列・測るものが無いときは何も変えずに走った (true)。
174
181
  */
175
- export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine => {
182
+ export type HeaderResizeMachine = SelectionEngine & {
183
+ /** Grows (1) or shrinks (-1) track `index` by one keyboard step (keyboardStepSize), with the strip's fan-out. / トラック `index` を 1 段大きく (1) / 小さく (-1) する (keyboardStepSize。ストリップと同じファンアウト)。 */
184
+ stepTrack: (axis: "row" | "col", index: number, direction: 1 | -1) => boolean
185
+ /** Fits column `index` to its content, as the strip double-click does (false when renderCell is set). / 列 `index` を中身に合わせる (ストリップのダブルクリックと同じ。renderCell があれば false)。 */
186
+ autofitColumn: (index: number) => boolean
187
+ }
188
+
189
+ /**
190
+ * Wires the resize/unhide machine onto both header bands; returns the §7.4 composite surface with the keyboard twins.
191
+ * 両ヘッダー帯へリサイズ / unhide 機械を結線し、§7.4 の合成面とキーボードの双子を返す。
192
+ */
193
+ export const useHeaderResize = (params: UseHeaderResizeParams): HeaderResizeMachine => {
176
194
  // paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
177
195
  const paramsRef = useRef(params)
178
196
  paramsRef.current = params
@@ -271,16 +289,17 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
271
289
  * 純関数ゲート (resizeGates.ts: 件数ゲート・丸め・§7.2 の押下トラック 0 化ガード・§5.4(b) の行軸
272
290
  * 非表示総数ゲート) を通してから resizeTracks を 1 回呼ぶ。拒否は onOperationRejected("resize")。
273
291
  */
274
- const applyEntries = useCallback((axis: "row" | "col", rawEntries: readonly ResizeEntry[], pressedIndex: number | null): void => {
292
+ const applyEntries = useCallback((axis: "row" | "col", rawEntries: readonly ResizeEntry[], pressedIndex: number | null): boolean => {
275
293
  const p = paramsRef.current
276
294
  const axisModel = axis === "col" ? p.colAxis : p.rowAxis
277
295
  // ゲートは純関数 (§4.2.10 の委譲) — ヘッダー由来のサイズは常に有限 ≥ 0 なので RangeError 枝は到達しない
278
296
  const result = gateResizeEntries(axisModel, rawEntries, pressedIndex, { maxOperationCells: p.maxOperationCells, rowHiddenLimit: ROW_HIDDEN_LIMIT, axisKind: axis })
279
297
  if (!result.ok) {
280
298
  p.onOperationRejected?.("resize", result.count, result.reason)
281
- return
299
+ return false
282
300
  }
283
301
  p.resizeTracks(axis, result.entries)
302
+ return true
284
303
  }, [])
285
304
 
286
305
  /** The up commit: same-value skip → fan-out targets → batch apply. / up 確定 (同値スキップ → ファンアウト → バッチ適用)。 */
@@ -306,11 +325,11 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
306
325
 
307
326
  /** Column autofit (§7.3 / v0.8 §4.2.10 / ADR-21): per-column canvas measurement over the union of both frozen bands and the visible window — `[0, R_eff) ∪ [rowStart, rowEnd] ∪ [rowCount − T_eff, rowCount)`, each filtered by sizeOf > 0 — in each cell's painted font, bucketed by font key. / 列 autofit (§7.3 / v0.8 §4.2.10 / ADR-21): 両凍結帯 + 可視ウィンドウの和集合 `[0, R_eff) ∪ [rowStart, rowEnd] ∪ [rowCount − T_eff, rowCount)` (各 sizeOf > 0) をセルの塗りフォントで計測、フォントキーでバケット化。 */
308
327
  const autofitColumns = useCallback(
309
- (band: HTMLElement, pressedIndex: number): void => {
328
+ (band: HTMLElement, pressedIndex: number): boolean => {
310
329
  const p = paramsRef.current
311
330
  const targets = collectTargets("col", pressedIndex)
312
331
  if (targets === null) {
313
- return
332
+ return false
314
333
  }
315
334
  // 帯件数は判断時ハンドル読み (ADR-21 / ADR-19 の判断時規律 — getFrozenSize().rows /
316
335
  // .trailingRows はエンジンクランプ済み実効値)。装着前 (null) は帯集合なし — 測定集合が
@@ -344,8 +363,9 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
344
363
  }
345
364
  // 計測対象の可視行がゼロ (全帯 + 窓が非表示) は no-op — Excel も計測対象
346
365
  // なしでは幅を変えない (0 計測 → 9px 潰しの破壊を作らない。R1(B) 所見)
366
+ // 測る行が無い / 測る書体が無い / 全空列は「当てるものが無い」実行で、拒否ではない (true)
347
367
  if (measuredRows.length === 0) {
348
- return
368
+ return true
349
369
  }
350
370
  // 上限ゲートは計測セル数単位 (§7.3 — トラック数単位では 16k 列 × 可視 40 行が素通り
351
371
  // する)。上限は autofit 専用の低い係数 (§13 (e) の設計返却 — 実測根拠は定数 docstring)。
@@ -354,24 +374,30 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
354
374
  const measureLimit = Math.floor(p.maxOperationCells / AUTOFIT_MEASURE_DIVISOR)
355
375
  if (measuredCells > measureLimit) {
356
376
  p.onOperationRejected?.("resize", measuredCells, "limit")
357
- return
377
+ return false
358
378
  }
359
379
  // canvas 生成 + computed font 取得はジェスチャ 1 回につき 1 度 (§7.3)。font は
360
380
  // ロングハンド合成の単一経路 (Firefox は font ショートハンドの computed 値を空で
361
381
  // 返すため、合成だけが全ブラウザで決定的)
362
382
  const context = document.createElement("canvas").getContext("2d")
363
383
  if (context === null) {
364
- return // 実ブラウザでは常に成立 (jsdom は 2d 未実装 — 計測面は E2E がピン)
384
+ return true // 実ブラウザでは常に成立 (jsdom は 2d 未実装 — 計測面は E2E がピン)
365
385
  }
366
386
  const bandStyle = getComputedStyle(band)
367
387
  // 既定フォント (書式なしセル) — 帯の computed style のロングハンド合成
368
388
  const baseFont = `${bandStyle.fontStyle} ${bandStyle.fontWeight} ${bandStyle.fontSize} ${bandStyle.fontFamily}`
389
+ // 見出しの行は書式でなくスタイルシートが太くする — 測る書体もセルと同じ太さにする。スタイルシートが読まれていなければ
390
+ // 見出しの行も帯と同じ太さで描かれるので、帯の太さがそのまま実際の描画の値
391
+ const headerWeight = bandStyle.getPropertyValue("--_aqsp-header-row-font-weight").trim()
392
+ const headerStyle = { fontStyle: bandStyle.fontStyle, fontWeight: headerWeight === "" ? bandStyle.fontWeight : headerWeight, fontSize: bandStyle.fontSize, fontFamily: bandStyle.fontFamily }
393
+ const headerFont = `${headerStyle.fontStyle} ${headerStyle.fontWeight} ${headerStyle.fontSize} ${headerStyle.fontFamily}`
369
394
  // CF は幅に効く規則 (bold / italic 設定) と stopIfTrue の障壁だけを 1 ジェスチャ 1 回抽出し列毎に
370
395
  // スライスする (実測 155 → 56 ms)。障壁を残すため走査は塗りと同じ位置で止まり bold / italic が
371
396
  // 厳密一致する。getConditionalFormats は getCellFormat を要する (root がマウント時に保証)
372
397
  const cfRules = p.adapter.getConditionalFormats === undefined ? null : widthRelevantRules(p.adapter.getConditionalFormats())
373
398
  const memo = createCfMemo()
374
399
  const fontKeys = new WeakMap<CellFormat, string>()
400
+ const headerFontKeys = new WeakMap<CellFormat, string>()
375
401
  // フォントキー毎のバケット (列 index + 表示文字列): ctx.font の代入は異なりフォント毎に 1 回
376
402
  const buckets = new Map<string, { col: number; text: string }[]>()
377
403
  for (const col of targets) {
@@ -384,7 +410,8 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
384
410
  const raw = p.adapter.getCellText(row, col)
385
411
  // 塗り書式 (base ⊕ 幅に効く CF) — raw は既読なので thunk は再読しない
386
412
  const painted = p.adapter.getCellFormat === undefined ? undefined : resolvePaintedFormat(p.adapter, colIndex, memo, row, col, () => raw)
387
- const font = painted === undefined ? baseFont : fontKeyOf(painted, fontKeys, bandStyle)
413
+ const isHeaderRow = row < p.headerRows
414
+ const font = painted === undefined ? (isHeaderRow ? headerFont : baseFont) : isHeaderRow ? fontKeyOf(painted, headerFontKeys, headerStyle) : fontKeyOf(painted, fontKeys, bandStyle)
388
415
  const text = formatCellValue(raw, painted === undefined ? undefined : painted.value)
389
416
  const bucket = buckets.get(font)
390
417
  if (bucket === undefined) {
@@ -415,13 +442,51 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
415
442
  }
416
443
  }
417
444
  if (entries.length === 0) {
418
- return
445
+ return true
446
+ }
447
+ return applyEntries("col", entries, pressedIndex)
448
+ },
449
+ [collectTargets, applyEntries],
450
+ )
451
+
452
+ const stepTrack = useCallback(
453
+ (axis: "row" | "col", index: number, direction: 1 | -1): boolean => {
454
+ const axisModel = axis === "col" ? paramsRef.current.colAxis : paramsRef.current.rowAxis
455
+ const size = keyboardStepSize(axisModel.sizeOf(index), direction)
456
+ // 端にあるトラックは寸法を保つ (当てるものが無い — 拒否ではないので onOperationRejected は鳴らさない)
457
+ if (size === axisModel.sizeOf(index)) {
458
+ return true
459
+ }
460
+ const targets = collectTargets(axis, index)
461
+ if (targets === null) {
462
+ return false
419
463
  }
420
- applyEntries("col", entries, pressedIndex)
464
+ return applyEntries(
465
+ axis,
466
+ targets.map((target) => ({ index: target, size })),
467
+ index,
468
+ )
421
469
  },
422
470
  [collectTargets, applyEntries],
423
471
  )
424
472
 
473
+ const autofitColumn = useCallback(
474
+ (index: number): boolean => {
475
+ const p = paramsRef.current
476
+ const band = p.colBandRef.current
477
+ // renderCell のカスタム DOM は measureText で測れない (帯のダブルクリックと同じ逸脱) — 帯の未装着は測る書体が無い
478
+ if (!p.autofitEnabled || band === null) {
479
+ return false
480
+ }
481
+ // 隠れた列は隠れたまま (戻すのは再表示だけ) — 走ったが何も変えない
482
+ if (p.colAxis.sizeOf(index) === 0) {
483
+ return true
484
+ }
485
+ return autofitColumns(band, index)
486
+ },
487
+ [autofitColumns],
488
+ )
489
+
425
490
  const { enabled, colBandRef, rowBandRef } = params
426
491
  useEffect(() => {
427
492
  const colBand = colBandRef.current
@@ -650,6 +715,8 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
650
715
 
651
716
  return useMemo(
652
717
  () => ({
718
+ stepTrack,
719
+ autofitColumn,
653
720
  isDragging: () => draggingRef.current,
654
721
  // リサイズ / unhide は選択矩形を配送しない — 配送モードは常に加算
655
722
  getDragMode: (): "add" | "subtract" | "point" => "add",
@@ -661,6 +728,6 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
661
728
  },
662
729
  cancelDrag: cleanupDrag,
663
730
  }),
664
- [cleanupDrag],
731
+ [cleanupDrag, stepTrack, autofitColumn],
665
732
  )
666
733
  }
package/src/index.ts CHANGED
@@ -46,7 +46,7 @@ export { type HtmlTableKind, type HtmlTableResult, parseHtmlTable, serializeHtml
46
46
  export type { NumberFormatDecodeOptions } from "./clipboard/numberFormatCodec.ts"
47
47
  export type { PasteMode, PasteOperation, PasteOptions } from "./clipboard/pasteSpecial.ts"
48
48
  export { estimatePasteCellCount, expandPaste } from "./clipboard/tiling.ts"
49
- export { parseTsv, serializeTsv } from "./clipboard/tsv.ts"
49
+ export { iterateTsvPrefixRows, iterateTsvRows, measureTsv, parseTsv, serializeClipboardTsv, serializeTsv } from "./clipboard/tsv.ts"
50
50
  export type { ClipInfo, PasteOutcome } from "./clipboard/useClipboard.ts"
51
51
  export type { BorderPreset } from "./commands/useCommands.ts"
52
52
  // renderCell の ctx 型 — prop が公開面にある以上、その引数型も名指しできなければならない
@@ -67,6 +67,12 @@ export type SpreadsheetCommand =
67
67
  | "hideColumns"
68
68
  | "unhideRows"
69
69
  | "unhideColumns"
70
+ // 寸法のキーボード操作 (WCAG 2.1.1 — 帯の境界のドラッグと同じ処理)
71
+ | "increaseColumnWidth"
72
+ | "decreaseColumnWidth"
73
+ | "increaseRowHeight"
74
+ | "decreaseRowHeight"
75
+ | "autoFitColumnWidth"
70
76
  // 拡張コマンド (既定 [] = 未束縛、再バインド可)
71
77
  | "formatGeneral"
72
78
  | "bordersAll"
@@ -147,6 +153,11 @@ export const SPREADSHEET_COMMANDS: readonly SpreadsheetCommand[] = [
147
153
  "hideColumns",
148
154
  "unhideRows",
149
155
  "unhideColumns",
156
+ "increaseColumnWidth",
157
+ "decreaseColumnWidth",
158
+ "increaseRowHeight",
159
+ "decreaseRowHeight",
160
+ "autoFitColumnWidth",
150
161
  "formatGeneral",
151
162
  "bordersAll",
152
163
  "pasteFormats",
@@ -498,8 +509,64 @@ export const BUILTIN_ROWS: readonly BuiltinRow[] = [
498
509
  command("hideRows", "Ctrl+Alt+Digit9", "idle", "hide the selected rows"),
499
510
  command("unhideRows", "Ctrl+Shift+Digit9", "idle", "unhide rows"),
500
511
  command("unhideColumns", "Ctrl+Shift+Digit0", "idle", "unhide columns"),
512
+ command("increaseColumnWidth", "Alt+Shift+ArrowRight", "idle", "widen the active column by one step (x 2^(1/4))"),
513
+ command("decreaseColumnWidth", "Alt+Shift+ArrowLeft", "idle", "narrow the active column by one step (/ 2^(1/4), never below 1 px)"),
514
+ command("increaseRowHeight", "Alt+Shift+ArrowDown", "idle", "make the active row taller by one step (x 2^(1/4))"),
515
+ command("decreaseRowHeight", "Alt+Shift+ArrowUp", "idle", "make the active row shorter by one step (/ 2^(1/4), never below 1 px)"),
516
+ command("autoFitColumnWidth", "Alt+Shift+Equal", "idle", "fit the active column to its content (the strip double-click)"),
501
517
  ]
502
518
 
519
+ /**
520
+ * The commands that change the size of the active track (WCAG 2.1.1 — the keyboard twins of the header strips), in the
521
+ * order the hidden input describes them through `aria-keyshortcuts`.
522
+ * アクティブなトラックの寸法を変えるコマンド (WCAG 2.1.1 — 帯の境界のキーボードの双子)。隠しの入力が `aria-keyshortcuts` で
523
+ * 示す順。
524
+ */
525
+ export const RESIZE_COMMANDS: readonly SpreadsheetCommand[] = Object.freeze(["increaseColumnWidth", "decreaseColumnWidth", "increaseRowHeight", "decreaseRowHeight", "autoFitColumnWidth"])
526
+
527
+ /** ARIA key names of the code tokens whose key differs from the token (US layout, unshifted). / トークンと名前が違うコードの ARIA のキー名 (US 配列・Shift なし)。 */
528
+ const ARIA_KEY_OF_CODE: Readonly<Record<string, string>> = Object.freeze({
529
+ Equal: "=",
530
+ Minus: "-",
531
+ Semicolon: ";",
532
+ Quote: "'",
533
+ Comma: ",",
534
+ Period: ".",
535
+ Slash: "/",
536
+ Backslash: "\\",
537
+ BracketLeft: "[",
538
+ BracketRight: "]",
539
+ Backquote: "`",
540
+ NumpadAdd: "+",
541
+ NumpadSubtract: "-",
542
+ NumpadMultiply: "*",
543
+ NumpadDivide: "/",
544
+ NumpadDecimal: ".",
545
+ NumpadEnter: "Enter",
546
+ })
547
+
548
+ /**
549
+ * One canonical chord in the `aria-keyshortcuts` syntax: `Ctrl` becomes `Control`, digits and punctuation become the
550
+ * key they produce on a US layout without Shift (`Equal` → `=`, `Digit5` → `5`), and every other token is already an
551
+ * ARIA key name. The layout-specific keys `IntlYen`, `IntlRo` and `IntlBackslash` have no fixed character, so a chord
552
+ * using one has no ARIA form (null).
553
+ * 正準のチョード 1 つの `aria-keyshortcuts` の書き方。`Ctrl` は `Control`、数字と記号は US 配列で Shift なしに生む文字
554
+ * (`Equal` → `=`、`Digit5` → `5`) にし、ほかのトークンはそのまま ARIA のキー名。配列ごとのキー `IntlYen` / `IntlRo` /
555
+ * `IntlBackslash` は文字が決まらないので、それを使うチョードは ARIA の形を持たない (null)。
556
+ *
557
+ * @param chord - A canonical chord (`Ctrl+Alt+Shift+KEY`). 正準のチョード。
558
+ * @returns The ARIA form, or null. ARIA の形、または null。
559
+ */
560
+ export const toAriaKeyShortcut = (chord: string): string | null => {
561
+ const parsed = parseChord(chord)
562
+ if (parsed.key === "IntlYen" || parsed.key === "IntlRo" || parsed.key === "IntlBackslash") {
563
+ return null
564
+ }
565
+ const digit = /^(?:Digit|Numpad)(\d)$/u.exec(parsed.key)
566
+ const key = digit !== null ? (digit[1] as string) : parsed.key in ARIA_KEY_OF_CODE ? (ARIA_KEY_OF_CODE[parsed.key] as string) : parsed.key
567
+ return `${parsed.ctrl ? "Control+" : ""}${parsed.alt ? "Alt+" : ""}${parsed.shift ? "Shift+" : ""}${key}`
568
+ }
569
+
503
570
  /** True when a row is consulted in the given column (`both` covers idle and editing). / 行が指定列で参照されるなら true (`both` は idle と編集の両方)。 */
504
571
  const rowInColumn = (row: BuiltinRow, column: "idle" | "editing"): boolean => row.column === "both" || row.column === column
505
572
 
@@ -581,6 +581,80 @@ export const readOnlyDeclared = (adapter: SpreadsheetDataAdapter): boolean => ad
581
581
  */
582
582
  export const isReadOnlyCell = (adapter: SpreadsheetDataAdapter, row: number, col: number): boolean => adapter.isCellReadOnly?.(row, col) === true
583
583
 
584
+ /**
585
+ * Every member of SpreadsheetDataAdapter, so a view can forward each one by name; the compile-time check below fails when
586
+ * the adapter type gains a member this list lacks.
587
+ * SpreadsheetDataAdapter の全メンバー。ビューが名前ごとに転送するための一覧で、型にメンバーが増えて一覧に無いときは下の
588
+ * コンパイル時の検査が落ちる。
589
+ */
590
+ const ADAPTER_MEMBERS = [
591
+ "rowCount",
592
+ "colCount",
593
+ "getCellText",
594
+ "applyEdits",
595
+ "findDataEdge",
596
+ "getCellFormat",
597
+ "getCellFormula",
598
+ "getCellNote",
599
+ "getCellComments",
600
+ "getCellHyperlink",
601
+ "getCellValidation",
602
+ "getConditionalFormats",
603
+ "isCellReadOnly",
604
+ "getCellAttributes",
605
+ "getRangeExtrema",
606
+ "evaluateFormula",
607
+ ] as const satisfies readonly (keyof SpreadsheetDataAdapter)[]
608
+
609
+ /** True when ADAPTER_MEMBERS names every member of the adapter type. / ADAPTER_MEMBERS が型の全メンバーを挙げているとき true。 */
610
+ const ADAPTER_MEMBERS_COMPLETE: [Exclude<keyof SpreadsheetDataAdapter, (typeof ADAPTER_MEMBERS)[number]>] extends [never] ? true : never = true
611
+ void ADAPTER_MEMBERS_COMPLETE
612
+
613
+ /** The read-only answer of a whole read-only grid: every cell. / グリッド全体を読み取り専用にしたときの答え (どのセルも読み取り専用)。 */
614
+ const EVERY_CELL_READ_ONLY = (): boolean => true
615
+
616
+ /**
617
+ * A view of `adapter` whose every cell is read-only (the `readOnly` prop): `isCellReadOnly` answers true for every cell
618
+ * whatever the adapter declares, so every read-only gate refuses every write and routes it to `onReadOnlyRejected`, and
619
+ * every other member reads the adapter live (getters run on the adapter, methods are bound to it once per function
620
+ * identity), so a class-based adapter with private fields or accessors keeps working through the view.
621
+ * 全てのセルを読み取り専用にした `adapter` のビュー (`readOnly` prop)。`isCellReadOnly` はアダプタの宣言に関わらずどのセルにも
622
+ * true を返すので、読み取り専用のゲートは全ての書き込みを拒み `onReadOnlyRejected` へ送る。ほかのメンバーはアダプタを毎回読む
623
+ * (アクセサはアダプタの上で動き、メソッドは関数の同一性ごとに 1 度だけアダプタへ束ねる) ので、private なフィールドやアクセサを
624
+ * 持つクラスのアダプタもビュー越しに動く。
625
+ *
626
+ * @param adapter - The host's adapter. ホストのアダプタ。
627
+ * @returns The read-only view. 読み取り専用のビュー。
628
+ */
629
+ export const readOnlyAdapterView = (adapter: SpreadsheetDataAdapter): SpreadsheetDataAdapter => {
630
+ const bound = new WeakMap<object, unknown>()
631
+ const view = {} as SpreadsheetDataAdapter
632
+ for (const member of ADAPTER_MEMBERS) {
633
+ if (member === "isCellReadOnly") {
634
+ continue
635
+ }
636
+ Object.defineProperty(view, member, {
637
+ enumerable: true,
638
+ get: () => {
639
+ const value: unknown = Reflect.get(adapter, member, adapter)
640
+ if (typeof value !== "function") {
641
+ return value
642
+ }
643
+ // メソッドの同一性を保つ (毎回 bind すると依存配列の比較や能力の読みのたびに別の関数になる)
644
+ const cached = bound.get(value)
645
+ if (cached !== undefined) {
646
+ return cached
647
+ }
648
+ const fresh = value.bind(adapter)
649
+ bound.set(value, fresh)
650
+ return fresh
651
+ },
652
+ })
653
+ }
654
+ Object.defineProperty(view, "isCellReadOnly", { enumerable: true, value: EVERY_CELL_READ_ONLY })
655
+ return view
656
+ }
657
+
584
658
  /** Host cell-attribute key grammar (v0.12): lowercase `data-*` only. / ホストセル属性キーの文法 (v0.12): 小文字 `data-*` のみ。 */
585
659
  const CELL_ATTRIBUTE_KEY = /^data-[a-z0-9-]+$/
586
660
 
@@ -371,7 +371,7 @@ export const placeBandSegments = (nodes: BandNodes, rect: BandRect, extents: Ban
371
371
  // 埋め色は族の CSS 変数を参照するインライン background (パレット交換に追随)、交点表示へ戻るとき解除
372
372
  if (crossTint !== last.crossTint) {
373
373
  last.crossTint = crossTint
374
- nodes.cross.style.background = crossTint === "intersection" ? "" : `var(--aqsp-band-${crossTint})`
374
+ nodes.cross.style.background = crossTint === "intersection" ? "" : `var(--_aqsp-band-${crossTint})`
375
375
  }
376
376
  if (rect.width !== last.crossWidth || rect.height !== last.crossHeight) {
377
377
  last.crossWidth = rect.width
@@ -490,6 +490,10 @@ type SelectionOverlayProps = {
490
490
  /** v0.9 capability: trailing frozen rows (the vertical twin of trailingCols). / v0.9 能力: 末尾凍結行数 (trailingCols の縦双子)。 */
491
491
  trailingRows?: number
492
492
  sinkAriaLabel: string
493
+ /** Whole-grid read-only (the `readOnly` prop): the hidden input carries `aria-readonly="true"`. / グリッド全体の読み取り専用 (`readOnly` prop)。隠しの入力が `aria-readonly="true"` を持つ。 */
494
+ sinkReadOnly: boolean
495
+ /** The keyboard resize chords in `aria-keyshortcuts` syntax, or undefined. / キーボードの寸法の操作のチョード (`aria-keyshortcuts` の書き方)。無ければ undefined。 */
496
+ sinkKeyShortcuts: string | undefined
493
497
  onSinkMouseDown: (event: MouseEvent) => void
494
498
  /**
495
499
  * v0.8 capability: the editing sink's paint (line box + vertical alignment) owned by the
@@ -683,6 +687,8 @@ export const SelectionOverlay = ({
683
687
  trailingCols,
684
688
  trailingRows,
685
689
  sinkAriaLabel,
690
+ sinkReadOnly,
691
+ sinkKeyShortcuts,
686
692
  onSinkMouseDown,
687
693
  editPaintRef,
688
694
  highlightRef,
@@ -1899,7 +1905,21 @@ export const SelectionOverlay = ({
1899
1905
  一度も変化しない — 変換中の属性変更 (Windows TSF が変換を強制終了する) を構造的に排除する。
1900
1906
  OSK の実際の表示は UA 依存: WebKit はフォーカス済み要素の変更を再評価する、Chromium はフォーカス取得時に
1901
1907
  しか評価しない。ハードウェアキーボードとデスクトップ IME は無影響 */}
1902
- <textarea ref={mergedSinkRef} className="aqsp-sink" aria-label={sinkAriaLabel} tabIndex={-1} defaultValue=" " inputMode={virtualKeyboard ? "text" : "none"} spellCheck={false} autoCorrect="off" autoCapitalize="off" autoComplete="off" data-aqsp-sink="" />
1908
+ <textarea
1909
+ ref={mergedSinkRef}
1910
+ className="aqsp-sink"
1911
+ aria-label={sinkAriaLabel}
1912
+ aria-readonly={sinkReadOnly ? "true" : undefined}
1913
+ aria-keyshortcuts={sinkKeyShortcuts}
1914
+ tabIndex={-1}
1915
+ defaultValue=" "
1916
+ inputMode={virtualKeyboard ? "text" : "none"}
1917
+ spellCheck={false}
1918
+ autoCorrect="off"
1919
+ autoCapitalize="off"
1920
+ autoComplete="off"
1921
+ data-aqsp-sink=""
1922
+ />
1903
1923
  {/* pointer-events: auto の例外第 2 号 (§8.1) — シンクより後置 = 最上層。enableFillHandle=false は
1904
1924
  DOM ごと抑止 (機械も不活性) */}
1905
1925
  {enableFillHandle ? <div ref={fillHandleNodeRef} className="aqsp-autofill-handle" data-aqsp-autofill-handle="" style={{ display: "none" }} /> : null}